mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(connection): test SSH tunnel profiles
This commit is contained in:
@@ -9,7 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Loader2, Plus, Trash2 } from "@lucide/vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
|
||||
import { createTunnelProfile, tunnelProfileSummary, type TunnelProfileType } from "@/lib/connection/tunnelProfiles";
|
||||
import { createTunnelProfile, createTunnelProfileTestGuard, tunnelProfileSummary, type TunnelProfileType } from "@/lib/connection/tunnelProfiles";
|
||||
import type { TunnelProfile } from "@/types/database";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
|
||||
@@ -21,6 +21,9 @@ const draft = ref<TunnelProfile[]>([]);
|
||||
const selectedId = ref<string | null>(null);
|
||||
const isSaving = ref(false);
|
||||
const hasInitializedDraft = ref(false);
|
||||
const isTesting = ref(false);
|
||||
const testResult = ref<{ ok: boolean; message: string } | null>(null);
|
||||
const testGuard = createTunnelProfileTestGuard();
|
||||
|
||||
function cloneProfiles(profiles: TunnelProfile[]): TunnelProfile[] {
|
||||
return JSON.parse(JSON.stringify(profiles)) as TunnelProfile[];
|
||||
@@ -54,6 +57,22 @@ const selectedSsh = computed(() => (selected.value?.type === "ssh" ? selected.va
|
||||
const selectedProxy = computed(() => (selected.value?.type === "proxy" ? selected.value : null));
|
||||
const selectedHttp = computed(() => (selected.value?.type === "http_tunnel" ? selected.value : null));
|
||||
|
||||
function invalidateProfileTest() {
|
||||
testGuard.invalidate();
|
||||
isTesting.value = false;
|
||||
testResult.value = null;
|
||||
}
|
||||
|
||||
// Profile tests are asynchronous, so any selection or configuration change
|
||||
// must invalidate the request before it can publish a stale result.
|
||||
watch(
|
||||
[selectedId, selectedSsh],
|
||||
() => {
|
||||
invalidateProfileTest();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function profileTypeLabel(profile: TunnelProfile): string {
|
||||
if (profile.type === "proxy") return "Proxy";
|
||||
if (profile.type === "http_tunnel") return t("connection.httpTunnel");
|
||||
@@ -96,6 +115,7 @@ function updateProxyType(value: unknown) {
|
||||
|
||||
async function save() {
|
||||
if (isSaving.value) return;
|
||||
invalidateProfileTest();
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await store.saveProfiles(cloneProfiles(draft.value));
|
||||
@@ -106,6 +126,25 @@ async function save() {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testSelected() {
|
||||
const profile = selectedSsh.value;
|
||||
if (!profile || isTesting.value) return;
|
||||
const profileSnapshot = cloneProfiles([profile])[0];
|
||||
const requestId = testGuard.start(profileSnapshot);
|
||||
isTesting.value = true;
|
||||
testResult.value = null;
|
||||
try {
|
||||
const message = await store.testProfile(profileSnapshot);
|
||||
if (!testGuard.isCurrent(requestId, selectedSsh.value)) return;
|
||||
testResult.value = { ok: true, message: message || t("settings.tunnelsTestSuccess") };
|
||||
} catch (error) {
|
||||
if (!testGuard.isCurrent(requestId, selectedSsh.value)) return;
|
||||
testResult.value = { ok: false, message: t("settings.tunnelsTestFailed", { message: translateBackendError(t, String(error)) }) };
|
||||
} finally {
|
||||
if (testGuard.isCurrent(requestId, selectedSsh.value)) isTesting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -244,7 +283,7 @@ async function save() {
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" size="sm" :disabled="!isDirty || isSaving" @click="save">
|
||||
<Loader2 v-if="isSaving" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("settings.tunnelsSave") }}
|
||||
@@ -252,7 +291,15 @@ async function save() {
|
||||
<Button type="button" variant="outline" size="sm" :disabled="!isDirty || isSaving" @click="resetDraft">
|
||||
{{ t("settings.tunnelsReset") }}
|
||||
</Button>
|
||||
<Button v-if="selectedSsh" type="button" variant="outline" size="sm" :disabled="isTesting || isSaving || !selectedSsh.host.trim()" @click="testSelected">
|
||||
<Loader2 v-if="isTesting" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
{{ isTesting ? t("settings.tunnelsTesting") : t("settings.tunnelsTest") }}
|
||||
</Button>
|
||||
<p v-if="isDirty" class="text-xs text-muted-foreground">{{ t("settings.tunnelsUnsavedHint") }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="testResult" class="text-xs" :class="testResult.ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500'">
|
||||
{{ testResult.message }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3116,6 +3116,10 @@ export default {
|
||||
tunnelsSaved: "Tunnel profiles saved",
|
||||
tunnelsSaveFailed: "Failed to save tunnel profiles: {message}",
|
||||
tunnelsUnsavedHint: "Unsaved changes",
|
||||
tunnelsTest: "Test",
|
||||
tunnelsTesting: "Testing...",
|
||||
tunnelsTestSuccess: "SSH tunnel connection successful",
|
||||
tunnelsTestFailed: "Tunnel test failed: {message}",
|
||||
redisTab: "Redis",
|
||||
shortcutsTab: "Shortcuts",
|
||||
snippetsTab: "Snippets",
|
||||
|
||||
@@ -2965,6 +2965,10 @@ export default withEnglishFallback({
|
||||
tunnelsSaved: "Perfiles de túnel guardados",
|
||||
tunnelsSaveFailed: "Error al guardar los perfiles de túnel: {message}",
|
||||
tunnelsUnsavedHint: "Cambios sin guardar",
|
||||
tunnelsTest: "Probar",
|
||||
tunnelsTesting: "Probando...",
|
||||
tunnelsTestSuccess: "Conexión del túnel SSH correcta",
|
||||
tunnelsTestFailed: "Error al probar el túnel: {message}",
|
||||
redisTab: "Redis",
|
||||
shortcutsTab: "Atajos",
|
||||
snippetsTab: "Fragmentos",
|
||||
|
||||
@@ -2963,6 +2963,10 @@ export default withEnglishFallback({
|
||||
tunnelsSaved: "Profili tunnel salvati",
|
||||
tunnelsSaveFailed: "Impossibile salvare i profili tunnel: {message}",
|
||||
tunnelsUnsavedHint: "Modifiche non salvate",
|
||||
tunnelsTest: "Prova",
|
||||
tunnelsTesting: "Prova in corso...",
|
||||
tunnelsTestSuccess: "Connessione tunnel SSH riuscita",
|
||||
tunnelsTestFailed: "Prova del tunnel non riuscita: {message}",
|
||||
redisTab: "Redis",
|
||||
shortcutsTab: "Scorciatoie",
|
||||
snippetsTab: "Snippet",
|
||||
|
||||
@@ -2964,6 +2964,10 @@ export default withEnglishFallback({
|
||||
tunnelsSaved: "トンネルプロファイルを保存しました",
|
||||
tunnelsSaveFailed: "トンネルプロファイルの保存に失敗しました: {message}",
|
||||
tunnelsUnsavedHint: "未保存の変更があります",
|
||||
tunnelsTest: "テスト",
|
||||
tunnelsTesting: "テスト中...",
|
||||
tunnelsTestSuccess: "SSH トンネル接続に成功しました",
|
||||
tunnelsTestFailed: "トンネルのテストに失敗しました: {message}",
|
||||
redisTab: "Redis",
|
||||
shortcutsTab: "ショートカット",
|
||||
snippetsTab: "スニペット",
|
||||
|
||||
@@ -2965,6 +2965,10 @@ export default withEnglishFallback({
|
||||
tunnelsSaved: "Perfis de túnel salvos",
|
||||
tunnelsSaveFailed: "Falha ao salvar os perfis de túnel: {message}",
|
||||
tunnelsUnsavedHint: "Alterações não salvas",
|
||||
tunnelsTest: "Testar",
|
||||
tunnelsTesting: "Testando...",
|
||||
tunnelsTestSuccess: "Conexão do túnel SSH bem-sucedida",
|
||||
tunnelsTestFailed: "Falha no teste do túnel: {message}",
|
||||
redisTab: "Redis",
|
||||
shortcutsTab: "Atalhos",
|
||||
snippetsTab: "Snippets",
|
||||
|
||||
@@ -3115,6 +3115,10 @@ export default withEnglishFallback({
|
||||
tunnelsSaved: "隧道档案已保存",
|
||||
tunnelsSaveFailed: "保存隧道档案失败:{message}",
|
||||
tunnelsUnsavedHint: "有未保存的修改",
|
||||
tunnelsTest: "测试",
|
||||
tunnelsTesting: "测试中...",
|
||||
tunnelsTestSuccess: "SSH 隧道连接成功",
|
||||
tunnelsTestFailed: "隧道测试失败:{message}",
|
||||
redisTab: "Redis",
|
||||
shortcutsTab: "快捷键",
|
||||
snippetsTab: "代码片段",
|
||||
|
||||
@@ -2828,6 +2828,10 @@ export default withEnglishFallback({
|
||||
tunnelsSaved: "隧道設定檔已儲存",
|
||||
tunnelsSaveFailed: "儲存隧道設定檔失敗:{message}",
|
||||
tunnelsUnsavedHint: "有未儲存的變更",
|
||||
tunnelsTest: "測試",
|
||||
tunnelsTesting: "測試中...",
|
||||
tunnelsTestSuccess: "SSH 通道連線成功",
|
||||
tunnelsTestFailed: "通道測試失敗:{message}",
|
||||
fontFamily: "字型",
|
||||
uiFontFamily: "介面字型",
|
||||
uiFontAppDefault: "DBX 預設",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createTunnelProfile, detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles";
|
||||
import { createTunnelProfile, createTunnelProfileTestGuard, detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles";
|
||||
import type { TunnelProfile } from "@/types/database";
|
||||
|
||||
function sshProfile(overrides: Partial<TunnelProfile> = {}): TunnelProfile {
|
||||
@@ -34,6 +34,37 @@ describe("tunnelProfileSummary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTunnelProfileTestGuard", () => {
|
||||
it("invalidates a request when the tested profile changes", () => {
|
||||
const guard = createTunnelProfileTestGuard();
|
||||
const profile = sshProfile();
|
||||
const requestId = guard.start(profile);
|
||||
|
||||
expect(guard.isCurrent(requestId, profile)).toBe(true);
|
||||
expect(guard.isCurrent(requestId, { ...profile, password: "changed" } as TunnelProfile)).toBe(false);
|
||||
});
|
||||
|
||||
it("invalidates a request after switching or removing the profile", () => {
|
||||
const guard = createTunnelProfileTestGuard();
|
||||
const requestId = guard.start(sshProfile());
|
||||
|
||||
guard.invalidate();
|
||||
|
||||
expect(guard.isCurrent(requestId, sshProfile())).toBe(false);
|
||||
expect(guard.isCurrent(requestId, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores an older response after a newer request starts", () => {
|
||||
const guard = createTunnelProfileTestGuard();
|
||||
const profile = sshProfile();
|
||||
const olderRequestId = guard.start(profile);
|
||||
const newerRequestId = guard.start(profile);
|
||||
|
||||
expect(guard.isCurrent(olderRequestId, profile)).toBe(false);
|
||||
expect(guard.isCurrent(newerRequestId, profile)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tunnelProfileReferenceLayer", () => {
|
||||
it("keeps only identity, enabled state, and the reference", () => {
|
||||
const profile = sshProfile();
|
||||
|
||||
@@ -61,6 +61,7 @@ export const saveConnections = forward("saveConnections");
|
||||
export const loadConnections = forward("loadConnections");
|
||||
export const loadTunnelProfiles = forward("loadTunnelProfiles");
|
||||
export const saveTunnelProfiles = forward("saveTunnelProfiles");
|
||||
export const testTunnelProfile = forward("testTunnelProfile");
|
||||
export const readKeychainPassword = forward("readKeychainPassword");
|
||||
export const readKeychainPasswords = forward("readKeychainPasswords");
|
||||
export const decryptConfig = forward("decryptConfig");
|
||||
|
||||
@@ -245,6 +245,10 @@ export async function saveTunnelProfiles(profiles: TunnelProfile[]): Promise<voi
|
||||
return post("/api/tunnel-profiles/save", { profiles });
|
||||
}
|
||||
|
||||
export async function testTunnelProfile(profile: TunnelProfile): Promise<string> {
|
||||
return post("/api/tunnel-profiles/test", profile);
|
||||
}
|
||||
|
||||
export async function readKeychainPassword(_service: string): Promise<string> {
|
||||
return ""; // Not available in web backend
|
||||
}
|
||||
|
||||
@@ -1120,6 +1120,10 @@ export async function saveTunnelProfiles(profiles: TunnelProfile[]): Promise<voi
|
||||
return invoke("save_tunnel_profiles", { profiles });
|
||||
}
|
||||
|
||||
export async function testTunnelProfile(profile: TunnelProfile): Promise<string> {
|
||||
return invoke("test_tunnel_profile", { profile });
|
||||
}
|
||||
|
||||
export async function readKeychainPassword(service: string): Promise<string> {
|
||||
return invoke("read_keychain_password", { service, account: null });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,29 @@ import type { TransportLayerConfig, TunnelProfile } from "@/types/database";
|
||||
|
||||
export type TunnelProfileType = TunnelProfile["type"];
|
||||
|
||||
export interface TunnelProfileTestGuard {
|
||||
invalidate: () => void;
|
||||
start: (profile: TunnelProfile) => number;
|
||||
isCurrent: (requestId: number, profile: TunnelProfile | null) => boolean;
|
||||
}
|
||||
|
||||
export function createTunnelProfileTestGuard(): TunnelProfileTestGuard {
|
||||
let requestId = 0;
|
||||
let testedProfile = "";
|
||||
|
||||
return {
|
||||
invalidate: () => {
|
||||
requestId++;
|
||||
testedProfile = "";
|
||||
},
|
||||
start: (profile) => {
|
||||
testedProfile = JSON.stringify(profile);
|
||||
return ++requestId;
|
||||
},
|
||||
isCurrent: (candidateRequestId, profile) => candidateRequestId === requestId && profile !== null && JSON.stringify(profile) === testedProfile,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTunnelProfile(type: TunnelProfileType): TunnelProfile {
|
||||
if (type === "proxy") {
|
||||
return {
|
||||
|
||||
@@ -44,5 +44,14 @@ export const useTunnelProfileStore = defineStore("tunnelProfiles", () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { profiles, isLoaded, init, refresh, profileById, saveProfiles };
|
||||
/**
|
||||
* Tests a profile's transport layer in isolation (no downstream database).
|
||||
* The draft profile is passed straight through, so it validates the values
|
||||
* currently in the editor rather than the last-saved copy.
|
||||
*/
|
||||
async function testProfile(profile: TunnelProfile): Promise<string> {
|
||||
return api.testTunnelProfile(profile);
|
||||
}
|
||||
|
||||
return { profiles, isLoaded, init, refresh, profileById, saveProfiles, testProfile };
|
||||
});
|
||||
|
||||
@@ -1536,6 +1536,52 @@ impl AppState {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Tests a shared tunnel profile in isolation (no downstream database), for
|
||||
/// the Test button in Settings > Tunnels. Only SSH profiles are checked:
|
||||
/// starting an SSH tunnel connects and authenticates eagerly, so a
|
||||
/// successful start verifies host reachability and credentials. Proxy and
|
||||
/// HTTP-tunnel layers connect lazily (nothing happens until traffic flows),
|
||||
/// so there is nothing to verify here without a target to probe.
|
||||
pub async fn test_tunnel_profile(&self, profile: &TransportLayerConfig) -> Result<String, String> {
|
||||
let TransportLayerConfig::Ssh(ssh) = profile else {
|
||||
return Err("Tunnel test is currently only supported for SSH profiles.".to_string());
|
||||
};
|
||||
let ssh = crate::ssh_config::resolve_ssh_tunnel_config(ssh);
|
||||
if ssh.host.trim().is_empty() {
|
||||
return Err("SSH host is required.".to_string());
|
||||
}
|
||||
let timeout = if ssh.connect_timeout_secs == 0 {
|
||||
crate::models::connection::default_ssh_connect_timeout_secs()
|
||||
} else {
|
||||
ssh.connect_timeout_secs
|
||||
};
|
||||
// A throwaway id so the probe never reuses or evicts a live tunnel, and
|
||||
// a sentinel forward target: SSH auth completes on connect, before any
|
||||
// channel to this target is opened, so it need not be reachable.
|
||||
let probe_id = format!("__tunnel_profile_test__:{}", uuid::Uuid::new_v4());
|
||||
let result = self
|
||||
.tunnels
|
||||
.start_tunnel(
|
||||
&probe_id,
|
||||
&ssh.host,
|
||||
ssh.port,
|
||||
&ssh.user,
|
||||
&ssh.password,
|
||||
&ssh.key_path,
|
||||
&ssh.key_passphrase,
|
||||
ssh.use_ssh_agent,
|
||||
&ssh.ssh_agent_sock_path,
|
||||
&ssh.auth_method,
|
||||
timeout,
|
||||
"127.0.0.1",
|
||||
1,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
self.tunnels.stop_tunnel(&probe_id).await;
|
||||
result.map(|_| "SSH tunnel connection successful".to_string())
|
||||
}
|
||||
|
||||
pub async fn connection_host_port(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
@@ -3713,6 +3759,48 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tunnel_profile_rejects_non_ssh_and_missing_host() {
|
||||
let (state, dir) = test_app_state().await;
|
||||
|
||||
// Non-SSH profiles cannot be tested in isolation (they connect lazily).
|
||||
let proxy = TransportLayerConfig::Proxy(ProxyTunnelConfig {
|
||||
id: "p1".to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
proxy_type: ProxyType::Socks5,
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 1080,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
profile_id: String::new(),
|
||||
});
|
||||
let err = state.test_tunnel_profile(&proxy).await.unwrap_err();
|
||||
assert!(err.contains("SSH"), "unexpected error: {err}");
|
||||
|
||||
// An SSH profile with no host fails fast rather than dialing an empty host.
|
||||
let ssh = TransportLayerConfig::Ssh(SshTunnelConfig {
|
||||
id: "s1".to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
host: String::new(),
|
||||
port: 22,
|
||||
user: "root".to_string(),
|
||||
password: String::new(),
|
||||
key_path: String::new(),
|
||||
key_passphrase: String::new(),
|
||||
connect_timeout_secs: 5,
|
||||
expose_lan: false,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
auth_method: "password".to_string(),
|
||||
profile_id: String::new(),
|
||||
});
|
||||
assert!(state.test_tunnel_profile(&ssh).await.is_err());
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_connection_attempt_cannot_replace_newer_pool() {
|
||||
let (state, dir) = test_app_state().await;
|
||||
|
||||
@@ -247,6 +247,7 @@ async fn main() {
|
||||
// Tunnel profiles
|
||||
.route("/tunnel-profiles/list", get(routes::tunnel_profiles::load_tunnel_profiles))
|
||||
.route("/tunnel-profiles/save", post(routes::tunnel_profiles::save_tunnel_profiles))
|
||||
.route("/tunnel-profiles/test", post(routes::tunnel_profiles::test_tunnel_profile))
|
||||
// Agent drivers
|
||||
.route("/agents/installed-local", get(routes::agents::list_installed_agents_local))
|
||||
.route("/agents/installed", get(routes::agents::list_installed_agents))
|
||||
|
||||
@@ -26,3 +26,10 @@ pub async fn save_tunnel_profiles(
|
||||
) -> Result<Json<()>, AppError> {
|
||||
state.app.storage.save_tunnel_profiles(&body.profiles).await.map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
pub async fn test_tunnel_profile(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(profile): Json<TransportLayerConfig>,
|
||||
) -> Result<Json<String>, AppError> {
|
||||
state.app.test_tunnel_profile(&profile).await.map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
@@ -17,3 +17,11 @@ pub async fn save_tunnel_profiles(
|
||||
) -> Result<(), String> {
|
||||
state.storage.save_tunnel_profiles(&profiles).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_tunnel_profile(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
profile: TransportLayerConfig,
|
||||
) -> Result<String, String> {
|
||||
state.test_tunnel_profile(&profile).await
|
||||
}
|
||||
|
||||
@@ -1275,6 +1275,7 @@ pub fn run() {
|
||||
commands::ssh_config::list_ssh_config_hosts,
|
||||
commands::tunnel_profiles::load_tunnel_profiles,
|
||||
commands::tunnel_profiles::save_tunnel_profiles,
|
||||
commands::tunnel_profiles::test_tunnel_profile,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
Reference in New Issue
Block a user