feat(connection): test SSH tunnel profiles

This commit is contained in:
rainhan
2026-07-13 17:51:51 +08:00
committed by GitHub
parent 27c60f0d30
commit c7991b316e
19 changed files with 256 additions and 4 deletions
@@ -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>
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -2964,6 +2964,10 @@ export default withEnglishFallback({
tunnelsSaved: "トンネルプロファイルを保存しました",
tunnelsSaveFailed: "トンネルプロファイルの保存に失敗しました: {message}",
tunnelsUnsavedHint: "未保存の変更があります",
tunnelsTest: "テスト",
tunnelsTesting: "テスト中...",
tunnelsTestSuccess: "SSH トンネル接続に成功しました",
tunnelsTestFailed: "トンネルのテストに失敗しました: {message}",
redisTab: "Redis",
shortcutsTab: "ショートカット",
snippetsTab: "スニペット",
+4
View File
@@ -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",
+4
View File
@@ -3115,6 +3115,10 @@ export default withEnglishFallback({
tunnelsSaved: "隧道档案已保存",
tunnelsSaveFailed: "保存隧道档案失败:{message}",
tunnelsUnsavedHint: "有未保存的修改",
tunnelsTest: "测试",
tunnelsTesting: "测试中...",
tunnelsTestSuccess: "SSH 隧道连接成功",
tunnelsTestFailed: "隧道测试失败:{message}",
redisTab: "Redis",
shortcutsTab: "快捷键",
snippetsTab: "代码片段",
+4
View File
@@ -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();
+1
View File
@@ -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");
+4
View File
@@ -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
}
+4
View File
@@ -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 {
+10 -1
View File
@@ -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 };
});
+88
View File
@@ -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;
+1
View File
@@ -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
}
+1
View File
@@ -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")