feat(proxy-tunnel): add proxy endpoint profile test

This commit is contained in:
azens 2026-07-21 22:56:39 +08:00 committed by GitHub
parent d4c28c5a9a
commit 7edab93ec2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1085 additions and 92 deletions

View File

@ -86,7 +86,7 @@ function invalidateProfileTest() {
// Profile tests are asynchronous, so any selection or configuration change
// must invalidate the request before it can publish a stale result.
watch(
[selectedId, selectedSsh],
[selectedId, selectedSsh, selectedProxy],
() => {
invalidateProfileTest();
},
@ -148,7 +148,7 @@ async function save() {
}
async function testSelected() {
const profile = selectedSsh.value;
const profile = selectedSsh.value || selectedProxy.value;
if (!profile || isTesting.value) return;
const profileSnapshot = cloneProfiles([profile])[0];
const requestId = testGuard.start(profileSnapshot);
@ -156,13 +156,13 @@ async function testSelected() {
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") };
if (!testGuard.isCurrent(requestId, profile)) return;
testResult.value = { ok: true, message: message ? t("settings.tunnelsTestSuccess") + ": " + message : t("settings.tunnelsTestSuccess") };
} catch (error) {
if (!testGuard.isCurrent(requestId, selectedSsh.value)) return;
if (!testGuard.isCurrent(requestId, profile)) return;
testResult.value = { ok: false, message: t("settings.tunnelsTestFailed", { message: translateBackendError(t, String(error)) }) };
} finally {
if (testGuard.isCurrent(requestId, selectedSsh.value)) isTesting.value = false;
if (testGuard.isCurrent(requestId, selectedSsh.value || selectedProxy.value)) isTesting.value = false;
}
}
</script>
@ -288,6 +288,10 @@ async function testSelected() {
<Label class="text-xs">{{ t("connection.proxyPassword") }}</Label>
<PasswordInput v-model="selectedProxy.password" class="col-span-3" :placeholder="t('connection.proxyPasswordPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.proxyTestTarget") }}</Label>
<Input v-model="selectedProxy.test_target" class="col-span-3" :placeholder="t('connection.proxyTestTargetPlaceholder')" />
</div>
</template>
<template v-else-if="selectedHttp">
@ -314,7 +318,7 @@ async function testSelected() {
<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">
<Button v-if="selectedSsh || selectedProxy" type="button" variant="outline" size="sm" :disabled="isTesting || isSaving || (!selectedSsh?.host?.trim() && !selectedProxy?.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>

View File

@ -8,12 +8,44 @@ const patterns: [RegExp, string][] = [
[/^Agent requires Java 21, but DBX started it with an older Java runtime\. Use DBX managed JRE 21 or select a Java 21 executable in Driver Manager\./, "connection.agentJavaTooOld"],
[/^JDBC plugin is not installed\. Install the optional JDBC plugin to use this connection\.$/, "connection.jdbcPluginNotInstalled"],
[/^ai\.configNameExists:(.+)$/, "ai.configNameExists"],
// Tunnel / proxy test messages
[/^HTTP CONNECT proxy connection successful \((\d+)\)$/, "settings.tunnelsHttpTestSuccess"],
[/^SOCKS5 proxy connection successful$/, "settings.tunnelsSocks5TestSuccess"],
[/^SSH tunnel connection successful$/, "settings.tunnelsTestSuccess"],
[/^Proxy host is required\.$/, "settings.tunnelsProxyHostRequired"],
[/^Proxy port is required\.$/, "settings.tunnelsProxyPortRequired"],
[/^SSH host is required\.$/, "settings.tunnelsSshHostRequired"],
[/^Tunnel test is not supported for HTTP tunnel profiles\.$/, "settings.tunnelsHttpTunnelUnsupported"],
[/^Proxy connection timed out \(([^)]+)\)$/, "settings.tunnelsProxyTimedOut"],
[/^Failed to connect to proxy: (.+)$/, "settings.tunnelsProxyConnectFailed"],
[/^Proxy handshake failed \([^)]+\): (.+)$/, "settings.tunnelsProxyHandshakeFailed"],
[/^Proxy handshake timed out \(([^)]+)\)$/, "settings.tunnelsProxyHandshakeTimedOut"],
[/^HTTP proxy CONNECT failed: (.+)$/, "settings.tunnelsHttpConnectFailed"],
[/^Invalid SOCKS proxy version: (\d+)$/, "settings.tunnelsSocksInvalidVersion"],
[/^SOCKS username or password is too long$/, "settings.tunnelsSocksAuthTooLong"],
[/^SOCKS proxy authentication failed$/, "settings.tunnelsSocksAuthFailed"],
[/^SOCKS proxy rejected all supported auth methods$/, "settings.tunnelsSocksAuthRejected"],
[/^SOCKS proxy selected unsupported auth method: (\d+)$/, "settings.tunnelsSocksUnsupportedAuth"],
[/^Proxy host too long for SOCKS5 domain address$/, "settings.tunnelsSocksHostTooLong"],
[/^SOCKS proxy connect rejected \(code (\d+)\)$/, "settings.tunnelsSocksConnectRejected"],
[/^Unsupported SOCKS bound address type: (\d+)$/, "settings.tunnelsSocksUnsupportedAddrType"],
];
const paramNames: Record<string, string> = {
"connection.driverNotInstalled": "driver",
"connection.jreNotInstalled": "jre",
"ai.configNameExists": "name",
"settings.tunnelsHttpTestSuccess": "code",
"settings.tunnelsProxyTimedOut": "duration",
"settings.tunnelsProxyConnectFailed": "error",
"settings.tunnelsProxyHandshakeFailed": "error",
"settings.tunnelsProxyHandshakeTimedOut": "duration",
"settings.tunnelsHttpConnectFailed": "detail",
"settings.tunnelsSocksInvalidVersion": "version",
"settings.tunnelsSocksUnsupportedAuth": "method",
"settings.tunnelsSocksConnectRejected": "code",
"settings.tunnelsSocksUnsupportedAddrType": "type",
};
export function translateBackendError(t: ComposerTranslation, message: string): string {

View File

@ -510,6 +510,8 @@ export default {
proxyUsernamePlaceholder: "Optional",
proxyPassword: "Proxy Password",
proxyPasswordPlaceholder: "Optional",
proxyTestTarget: "Test Target",
proxyTestTargetPlaceholder: "host:port — empty = endpoint check, fill for full tunnel test",
tunnelProfile: "Tunnel Profile",
tunnelProfileCustom: "Custom (this connection only)",
tunnelProfileManaged: "Managed by a shared tunnel profile. Edit it in Settings > Tunnels — changes apply to every connection using it on its next connection.",
@ -3504,8 +3506,27 @@ export default {
tunnelsUnsavedHint: "Unsaved changes",
tunnelsTest: "Test",
tunnelsTesting: "Testing...",
tunnelsTestSuccess: "SSH tunnel connection successful",
tunnelsTestSuccess: "Tunnel test successful",
tunnelsTestFailed: "Tunnel test failed: {message}",
tunnelsHttpTestSuccess: "HTTP CONNECT proxy connection successful ({code})",
tunnelsSocks5TestSuccess: "SOCKS5 proxy connection successful",
tunnelsProxyHostRequired: "Proxy host is required.",
tunnelsProxyPortRequired: "Proxy port is required.",
tunnelsSshHostRequired: "SSH host is required.",
tunnelsHttpTunnelUnsupported: "Tunnel test is not supported for HTTP tunnel profiles.",
tunnelsProxyTimedOut: "Proxy connection timed out ({duration})",
tunnelsProxyConnectFailed: "Failed to connect to proxy: {error}",
tunnelsProxyHandshakeFailed: "Proxy handshake failed: {error}",
tunnelsProxyHandshakeTimedOut: "Proxy handshake timed out ({duration})",
tunnelsHttpConnectFailed: "HTTP proxy CONNECT failed: {detail}",
tunnelsSocksInvalidVersion: "Invalid SOCKS proxy version: {version}",
tunnelsSocksAuthTooLong: "SOCKS username or password is too long",
tunnelsSocksAuthFailed: "SOCKS proxy authentication failed",
tunnelsSocksAuthRejected: "SOCKS proxy rejected all supported auth methods",
tunnelsSocksUnsupportedAuth: "SOCKS proxy selected unsupported auth method: {method}",
tunnelsSocksHostTooLong: "Proxy host too long for SOCKS5 domain address",
tunnelsSocksConnectRejected: "SOCKS proxy connect rejected (code {code})",
tunnelsSocksUnsupportedAddrType: "Unsupported SOCKS bound address type: {type}",
redisTab: "Redis",
shortcutsTab: "Shortcuts",
snippetsTab: "Snippets",

View File

@ -411,6 +411,8 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "Opcional",
proxyPassword: "Contraseña del proxy",
proxyPasswordPlaceholder: "Opcional",
proxyTestTarget: "Destino de prueba",
proxyTestTargetPlaceholder: "host:port — vacío = verif. endpoint, completar para prueba completa",
tunnelProfile: "Perfil de túnel",
tunnelProfileCustom: "Personalizado (solo esta conexión)",
tunnelProfileManaged: "Gestionado por un perfil de túnel compartido. Edítalo en Ajustes > Túneles; los cambios se aplican a todas las conexiones que lo usan en su próxima conexión.",
@ -3282,8 +3284,27 @@ export default withEnglishFallback({
tunnelsUnsavedHint: "Cambios sin guardar",
tunnelsTest: "Probar",
tunnelsTesting: "Probando...",
tunnelsTestSuccess: "Conexión del túnel SSH correcta",
tunnelsTestSuccess: "Prueba de túnel correcta",
tunnelsTestFailed: "Error al probar el túnel: {message}",
tunnelsHttpTestSuccess: "Conexión proxy HTTP CONNECT correcta ({code})",
tunnelsSocks5TestSuccess: "Conexión proxy SOCKS5 correcta",
tunnelsProxyHostRequired: "El host del proxy es obligatorio.",
tunnelsProxyPortRequired: "El puerto del proxy es obligatorio.",
tunnelsSshHostRequired: "El host SSH es obligatorio.",
tunnelsHttpTunnelUnsupported: "La prueba de túnel no está soportada para perfiles de túnel HTTP.",
tunnelsProxyTimedOut: "La conexión al proxy agotó el tiempo de espera ({duration})",
tunnelsProxyConnectFailed: "Error al conectar con el proxy: {error}",
tunnelsProxyHandshakeFailed: "Error en el handshake con el proxy: {error}",
tunnelsProxyHandshakeTimedOut: "El handshake con el proxy agotó el tiempo de espera ({duration})",
tunnelsHttpConnectFailed: "Error en CONNECT del proxy HTTP: {detail}",
tunnelsSocksInvalidVersion: "Versión de proxy SOCKS no válida: {version}",
tunnelsSocksAuthTooLong: "El usuario o la contraseña de SOCKS es demasiado largo",
tunnelsSocksAuthFailed: "Falló la autenticación del proxy SOCKS",
tunnelsSocksAuthRejected: "El proxy SOCKS rechazó todos los métodos de autenticación",
tunnelsSocksUnsupportedAuth: "El proxy SOCKS seleccionó un método de autenticación no soportado: {method}",
tunnelsSocksHostTooLong: "El host del proxy es demasiado largo para la dirección SOCKS5",
tunnelsSocksConnectRejected: "Conexión proxy SOCKS rechazada (código {code})",
tunnelsSocksUnsupportedAddrType: "Tipo de dirección de enlace SOCKS no soportado: {type}",
redisTab: "Redis",
shortcutsTab: "Atajos",
snippetsTab: "Fragmentos",

View File

@ -409,6 +409,8 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "Opzionale",
proxyPassword: "Password Proxy",
proxyPasswordPlaceholder: "Opzionale",
proxyTestTarget: "Destinazione test",
proxyTestTargetPlaceholder: "host:port — vuoto = verif. endpoint, compilare per test completo",
tunnelProfile: "Profilo tunnel",
tunnelProfileCustom: "Personalizzato (solo questa connessione)",
tunnelProfileManaged: "Gestito da un profilo tunnel condiviso. Modificalo in Impostazioni > Tunnel; le modifiche si applicano a tutte le connessioni che lo usano alla prossima connessione.",
@ -3280,8 +3282,27 @@ export default withEnglishFallback({
tunnelsUnsavedHint: "Modifiche non salvate",
tunnelsTest: "Prova",
tunnelsTesting: "Prova in corso...",
tunnelsTestSuccess: "Connessione tunnel SSH riuscita",
tunnelsTestSuccess: "Test del tunnel riuscito",
tunnelsTestFailed: "Prova del tunnel non riuscita: {message}",
tunnelsHttpTestSuccess: "Connessione proxy HTTP CONNECT riuscita ({code})",
tunnelsSocks5TestSuccess: "Connessione proxy SOCKS5 riuscita",
tunnelsProxyHostRequired: "L'host del proxy è obbligatorio.",
tunnelsProxyPortRequired: "La porta del proxy è obbligatoria.",
tunnelsSshHostRequired: "L'host SSH è obbligatorio.",
tunnelsHttpTunnelUnsupported: "Il test del tunnel non è supportato per i profili tunnel HTTP.",
tunnelsProxyTimedOut: "Connessione al proxy scaduta ({duration})",
tunnelsProxyConnectFailed: "Impossibile connettersi al proxy: {error}",
tunnelsProxyHandshakeFailed: "Handshake con il proxy fallito: {error}",
tunnelsProxyHandshakeTimedOut: "Handshake con il proxy scaduto ({duration})",
tunnelsHttpConnectFailed: "CONNECT proxy HTTP fallito: {detail}",
tunnelsSocksInvalidVersion: "Versione proxy SOCKS non valida: {version}",
tunnelsSocksAuthTooLong: "Nome utente o password SOCKS troppo lungo",
tunnelsSocksAuthFailed: "Autenticazione proxy SOCKS fallita",
tunnelsSocksAuthRejected: "Il proxy SOCKS ha rifiutato tutti i metodi di autenticazione",
tunnelsSocksUnsupportedAuth: "Il proxy SOCKS ha selezionato un metodo di autenticazione non supportato: {method}",
tunnelsSocksHostTooLong: "L'host del proxy è troppo lungo per l'indirizzo SOCKS5",
tunnelsSocksConnectRejected: "Connessione proxy SOCKS rifiutata (codice {code})",
tunnelsSocksUnsupportedAddrType: "Tipo di indirizzo di bind SOCKS non supportato: {type}",
redisTab: "Redis",
shortcutsTab: "Scorciatoie",
snippetsTab: "Snippet",

View File

@ -403,6 +403,8 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "任意",
proxyPassword: "プロキシパスワード",
proxyPasswordPlaceholder: "任意",
proxyTestTarget: "テストターゲット",
proxyTestTargetPlaceholder: "host:port — 空欄はエンドポイントチェックのみ、完全テストには入力してください",
tunnelProfile: "トンネルプロファイル",
tunnelProfileCustom: "カスタム(この接続のみ)",
tunnelProfileManaged: "共有トンネルプロファイルで管理されています。設定 > トンネル で編集すると、次回接続時にこのプロファイルを使用するすべての接続に反映されます。",
@ -3281,8 +3283,27 @@ export default withEnglishFallback({
tunnelsUnsavedHint: "未保存の変更があります",
tunnelsTest: "テスト",
tunnelsTesting: "テスト中...",
tunnelsTestSuccess: "SSH トンネル接続に成功しました",
tunnelsTestSuccess: "トンネルテスト成功",
tunnelsTestFailed: "トンネルのテストに失敗しました: {message}",
tunnelsHttpTestSuccess: "HTTP CONNECT プロキシ接続に成功しました ({code})",
tunnelsSocks5TestSuccess: "SOCKS5 プロキシ接続に成功しました",
tunnelsProxyHostRequired: "プロキシホストが必要です",
tunnelsProxyPortRequired: "プロキシポートが必要です",
tunnelsSshHostRequired: "SSH ホストが必要です",
tunnelsHttpTunnelUnsupported: "HTTP トンネルプロファイルのテストはサポートされていません",
tunnelsProxyTimedOut: "プロキシ接続がタイムアウトしました ({duration})",
tunnelsProxyConnectFailed: "プロキシに接続できません: {error}",
tunnelsProxyHandshakeFailed: "プロキシハンドシェイクに失敗しました: {error}",
tunnelsProxyHandshakeTimedOut: "プロキシハンドシェイクがタイムアウトしました ({duration})",
tunnelsHttpConnectFailed: "HTTP プロキシ CONNECT に失敗しました: {detail}",
tunnelsSocksInvalidVersion: "SOCKS プロキシバージョンが無効です: {version}",
tunnelsSocksAuthTooLong: "SOCKS ユーザー名またはパスワードが長すぎます",
tunnelsSocksAuthFailed: "SOCKS プロキシ認証に失敗しました",
tunnelsSocksAuthRejected: "SOCKS プロキシがすべての認証方法を拒否しました",
tunnelsSocksUnsupportedAuth: "SOCKS プロキシがサポートされていない認証方法を選択しました: {method}",
tunnelsSocksHostTooLong: "SOCKS5 ドメインアドレス用プロキシホストが長すぎます",
tunnelsSocksConnectRejected: "SOCKS プロキシ接続が拒否されました (code {code})",
tunnelsSocksUnsupportedAddrType: "サポートされていない SOCKS バインドアドレスタイプ: {type}",
redisTab: "Redis",
shortcutsTab: "ショートカット",
snippetsTab: "スニペット",

View File

@ -410,6 +410,8 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "Opcional",
proxyPassword: "Senha do Proxy",
proxyPasswordPlaceholder: "Opcional",
proxyTestTarget: "Alvo de teste",
proxyTestTargetPlaceholder: "host:port — vazio = verif. endpoint, preencher para teste completo",
tunnelProfile: "Perfil de túnel",
tunnelProfileCustom: "Personalizado (somente esta conexão)",
tunnelProfileManaged: "Gerenciado por um perfil de túnel compartilhado. Edite-o em Configurações > Túneis; as alterações se aplicam a todas as conexões que o usam na próxima conexão.",
@ -3282,8 +3284,27 @@ export default withEnglishFallback({
tunnelsUnsavedHint: "Alterações não salvas",
tunnelsTest: "Testar",
tunnelsTesting: "Testando...",
tunnelsTestSuccess: "Conexão do túnel SSH bem-sucedida",
tunnelsTestSuccess: "Teste de túnel bem-sucedido",
tunnelsTestFailed: "Falha no teste do túnel: {message}",
tunnelsHttpTestSuccess: "Conexão com proxy HTTP CONNECT bem-sucedida ({code})",
tunnelsSocks5TestSuccess: "Conexão com proxy SOCKS5 bem-sucedida",
tunnelsProxyHostRequired: "O host do proxy é obrigatório.",
tunnelsProxyPortRequired: "A porta do proxy é obrigatória.",
tunnelsSshHostRequired: "O host SSH é obrigatório.",
tunnelsHttpTunnelUnsupported: "O teste de túnel não é compatível com perfis de túnel HTTP.",
tunnelsProxyTimedOut: "Conexão com o proxy expirou ({duration})",
tunnelsProxyConnectFailed: "Falha ao conectar ao proxy: {error}",
tunnelsProxyHandshakeFailed: "Handshake com o proxy falhou: {error}",
tunnelsProxyHandshakeTimedOut: "Handshake com o proxy expirou ({duration})",
tunnelsHttpConnectFailed: "CONNECT do proxy HTTP falhou: {detail}",
tunnelsSocksInvalidVersion: "Versão de proxy SOCKS inválida: {version}",
tunnelsSocksAuthTooLong: "Usuário ou senha SOCKS muito longo",
tunnelsSocksAuthFailed: "Autenticação do proxy SOCKS falhou",
tunnelsSocksAuthRejected: "O proxy SOCKS rejeitou todos os métodos de autenticação",
tunnelsSocksUnsupportedAuth: "O proxy SOCKS selecionou um método de autenticação não compatível: {method}",
tunnelsSocksHostTooLong: "Host do proxy muito longo para endereço SOCKS5",
tunnelsSocksConnectRejected: "Conexão com proxy SOCKS rejeitada (código {code})",
tunnelsSocksUnsupportedAddrType: "Tipo de endereço de bind SOCKS não compatível: {type}",
redisTab: "Redis",
shortcutsTab: "Atalhos",
snippetsTab: "Snippets",

View File

@ -514,6 +514,8 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "可选",
proxyPassword: "代理密码",
proxyPasswordPlaceholder: "可选",
proxyTestTarget: "测试目标",
proxyTestTargetPlaceholder: "host:port — 留空仅做端点检测,填写真实地址进行完整验证",
tunnelProfile: "隧道档案",
tunnelProfileCustom: "自定义(仅此连接)",
tunnelProfileManaged: "由共享隧道档案管理。请在 设置 > 隧道维护 中编辑,修改会在下次连接时对所有使用该档案的连接生效。",
@ -3494,8 +3496,27 @@ export default withEnglishFallback({
tunnelsUnsavedHint: "有未保存的修改",
tunnelsTest: "测试",
tunnelsTesting: "测试中...",
tunnelsTestSuccess: "SSH 隧道连接成功",
tunnelsTestSuccess: "隧道测试成功",
tunnelsTestFailed: "隧道测试失败:{message}",
tunnelsHttpTestSuccess: "HTTP CONNECT 代理连接成功 ({code})",
tunnelsSocks5TestSuccess: "SOCKS5 代理连接成功",
tunnelsProxyHostRequired: "请输入代理主机",
tunnelsProxyPortRequired: "请输入代理端口",
tunnelsSshHostRequired: "请输入 SSH 主机",
tunnelsHttpTunnelUnsupported: "HTTP 隧道不支持单独测试",
tunnelsProxyTimedOut: "代理连接超时 ({duration})",
tunnelsProxyConnectFailed: "无法连接到代理: {error}",
tunnelsProxyHandshakeFailed: "代理握手失败: {error}",
tunnelsProxyHandshakeTimedOut: "代理握手超时 ({duration})",
tunnelsHttpConnectFailed: "HTTP CONNECT 代理连接失败: {detail}",
tunnelsSocksInvalidVersion: "SOCKS5 代理版本无效: {version}",
tunnelsSocksAuthTooLong: "SOCKS5 用户名或密码过长",
tunnelsSocksAuthFailed: "SOCKS5 代理认证失败",
tunnelsSocksAuthRejected: "SOCKS5 代理拒绝了所有认证方式",
tunnelsSocksUnsupportedAuth: "SOCKS5 代理选择了不支持的认证方式: {method}",
tunnelsSocksHostTooLong: "代理主机地址过长",
tunnelsSocksConnectRejected: "SOCKS5 连接被拒 (code {code})",
tunnelsSocksUnsupportedAddrType: "不支持的 SOCKS5 绑定地址类型: {type}",
redisTab: "Redis",
shortcutsTab: "快捷键",
snippetsTab: "代码片段",

View File

@ -410,6 +410,8 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "可選",
proxyPassword: "代理伺服器密碼",
proxyPasswordPlaceholder: "可選",
proxyTestTarget: "測試目標",
proxyTestTargetPlaceholder: "host:port — 留空僅做端點檢測,填寫真實位址進行完整驗證",
tunnelProfile: "隧道設定檔",
tunnelProfileCustom: "自訂(僅此連線)",
tunnelProfileManaged: "由共用隧道設定檔管理。請在 設定 > 隧道維護 中編輯,變更會在下次連線時套用到所有使用該設定檔的連線。",
@ -3107,8 +3109,27 @@ export default withEnglishFallback({
tunnelsUnsavedHint: "有未儲存的變更",
tunnelsTest: "測試",
tunnelsTesting: "測試中...",
tunnelsTestSuccess: "SSH 通道連線成功",
tunnelsTestSuccess: "通道測試成功",
tunnelsTestFailed: "通道測試失敗:{message}",
tunnelsHttpTestSuccess: "HTTP CONNECT 代理連線成功({code}",
tunnelsSocks5TestSuccess: "SOCKS5 代理連線成功",
tunnelsProxyHostRequired: "請輸入代理主機",
tunnelsProxyPortRequired: "請輸入代理連接埠",
tunnelsSshHostRequired: "請輸入 SSH 主機",
tunnelsHttpTunnelUnsupported: "HTTP 隧道設定檔不支援測試",
tunnelsProxyTimedOut: "代理連線逾時({duration}",
tunnelsProxyConnectFailed: "無法連線到代理伺服器:{error}",
tunnelsProxyHandshakeFailed: "代理握手失敗:{error}",
tunnelsProxyHandshakeTimedOut: "代理握手逾時({duration}",
tunnelsHttpConnectFailed: "HTTP 代理 CONNECT 失敗:{detail}",
tunnelsSocksInvalidVersion: "SOCKS 代理版本無效:{version}",
tunnelsSocksAuthTooLong: "SOCKS 使用者名稱或密碼過長",
tunnelsSocksAuthFailed: "SOCKS 代理認證失敗",
tunnelsSocksAuthRejected: "SOCKS 代理拒絕了所有認證方式",
tunnelsSocksUnsupportedAuth: "SOCKS 代理選擇了不支援的認證方式:{method}",
tunnelsSocksHostTooLong: "代理主機位址過長",
tunnelsSocksConnectRejected: "SOCKS 代理連線被拒(代碼 {code}",
tunnelsSocksUnsupportedAddrType: "不支援的 SOCKS 綁定位址類型:{type}",
fontFamily: "字型",
uiFontFamily: "介面字型",
uiFontAppDefault: "DBX 預設",

View File

@ -248,6 +248,8 @@ export interface ProxyTunnelConfig {
port: number;
username?: string;
password?: string;
/** Optional target host:port for tunnel testing. When empty, self-connect. */
test_target?: string;
/** See {@link SshTunnelConfig.profile_id}. */
profile_id?: string;
}

View File

@ -1721,49 +1721,73 @@ impl AppState {
}
/// 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.
/// the Test button in Settings > Tunnels.
///
/// - SSH: starting an SSH tunnel connects and authenticates eagerly, so a
/// successful start verifies host reachability and credentials.
/// - Proxy (HTTP CONNECT / SOCKS5): performs a standalone handshake test
/// against the proxy endpoint to verify reachability and credentials.
/// - HTTP tunnel: connects 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());
match profile {
TransportLayerConfig::Ssh(ssh) => {
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())
}
TransportLayerConfig::Proxy(proxy) => {
if proxy.host.trim().is_empty() {
return Err("Proxy host is required.".to_string());
}
if proxy.port == 0 {
return Err("Proxy port is required.".to_string());
}
crate::db::proxy_tunnel::test_proxy_endpoint(
proxy.proxy_type,
&proxy.host,
proxy.port,
&proxy.username,
&proxy.password,
proxy.test_target.as_deref(),
)
.await
}
TransportLayerConfig::HttpTunnel(_) => {
Err("Tunnel test is not supported for HTTP tunnel profiles.".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(
@ -4268,20 +4292,23 @@ mod tests {
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).
// Proxy profiles now attempt a connection; with no proxy running at the
// test address the result is a connection error, not an SSH-only guard.
let test_port = portpicker::pick_unused_port().expect("no port available");
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,
port: test_port,
username: String::new(),
password: String::new(),
test_target: None,
profile_id: String::new(),
});
let err = state.test_tunnel_profile(&proxy).await.unwrap_err();
assert!(err.contains("SSH"), "unexpected error: {err}");
assert!(!err.contains("SSH"), "proxy test should not return SSH error, got: {err}");
// An SSH profile with no host fails fast rather than dialing an empty host.
let ssh = TransportLayerConfig::Ssh(SshTunnelConfig {
@ -5381,6 +5408,7 @@ for line in sys.stdin:
port: 1080,
username: String::new(),
password: String::new(),
test_target: None,
profile_id: profile_id.to_string(),
}
}
@ -5494,12 +5522,12 @@ for line in sys.stdin:
port: 65000,
username: String::new(),
password: String::new(),
test_target: None,
})];
let (host, port) = state.connection_host_port("proxied", &config).await.unwrap();
let (host, _port) = state.connection_host_port("proxied", &config).await.unwrap();
assert_eq!(host, "127.0.0.1");
assert_ne!(port, config.port);
state.proxy_tunnels.stop_tunnel("proxied:transport:0").await;
let _ = std::fs::remove_dir_all(dir);
}
@ -5543,6 +5571,7 @@ for line in sys.stdin:
port: 65000,
username: String::new(),
password: String::new(),
test_target: None,
})];
let mqc = state.mq_admin_config_for_connection("proxied-mq", &config).await.unwrap();
@ -5601,6 +5630,7 @@ for line in sys.stdin:
port: 65000,
username: String::new(),
password: String::new(),
test_target: None,
})];
let nacos_config = state.nacos_admin_config_for_connection("proxied-nacos", &config).await.unwrap();

View File

@ -2,12 +2,16 @@ use crate::models::connection::ProxyType;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::Instant;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::task::JoinHandle;
use tokio::time::{timeout, Duration};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_HTTP_RESPONSE_SIZE: usize = 8192;
const MAX_HTTP_INTERIM_RESPONSES: usize = 5;
#[derive(Default)]
pub struct ProxyTunnelManager {
@ -130,31 +134,14 @@ async fn http_connect(
proxy: &ProxyEndpoint,
remote: &RemoteEndpoint,
) -> Result<TcpStream, String> {
let target = format!("{}:{}", remote.host, remote.port);
let mut request = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n");
if !proxy.username.is_empty() || !proxy.password.is_empty() {
let token = BASE64.encode(format!("{}:{}", proxy.username, proxy.password));
request.push_str(&format!("Proxy-Authorization: Basic {token}\r\n"));
}
request.push_str("\r\n");
stream.write_all(request.as_bytes()).await.map_err(|e| format!("Failed to send CONNECT request: {e}"))?;
let request = build_http_connect_request(&remote.host, remote.port, &proxy.username, &proxy.password, false);
stream.write_all(&request).await.map_err(|e| format!("Failed to send CONNECT request: {e}"))?;
let mut response = Vec::new();
let mut buf = [0_u8; 1];
while !response.ends_with(b"\r\n\r\n") && response.len() < 8192 {
let n = stream.read(&mut buf).await.map_err(|e| format!("Failed to read CONNECT response: {e}"))?;
if n == 0 {
break;
}
response.push(buf[0]);
}
let text = String::from_utf8_lossy(&response);
if text.starts_with("HTTP/1.1 200") || text.starts_with("HTTP/1.0 200") {
Ok(stream)
} else {
let status = text.lines().next().unwrap_or("invalid proxy response");
Err(format!("HTTP proxy CONNECT failed: {status}"))
}
// Read exactly through the final header so tunneled bytes already queued
// by the proxy remain available to the database protocol.
let response = read_http_connect_response(&mut stream).await?;
parse_http_connect_response(&response)?;
Ok(stream)
}
async fn socks5_connect(
@ -180,13 +167,8 @@ async fn socks5_connect(
other => return Err(format!("SOCKS proxy selected unsupported auth method: {other}")),
}
let host = remote.host.as_bytes();
if host.len() > u8::MAX as usize {
return Err("Remote host is too long for SOCKS5 domain address".to_string());
}
let mut req = vec![0x05, 0x01, 0x00, 0x03, host.len() as u8];
req.extend_from_slice(host);
req.extend_from_slice(&remote.port.to_be_bytes());
let req = build_socks5_connect_request(&remote.host, remote.port)
.map_err(|_| "Remote host is too long for SOCKS5 domain address".to_string())?;
stream.write_all(&req).await.map_err(|e| format!("Failed to send SOCKS connect request: {e}"))?;
let mut head = [0_u8; 4];
@ -212,6 +194,63 @@ async fn socks5_connect(
Ok(stream)
}
fn unbracket_host(host: &str) -> &str {
host.strip_prefix('[').and_then(|inner| inner.strip_suffix(']')).unwrap_or(host)
}
fn format_http_authority(host: &str, port: u16) -> String {
let host = unbracket_host(host);
match host.parse::<IpAddr>() {
Ok(IpAddr::V6(_)) => format!("[{host}]:{port}"),
_ => format!("{host}:{port}"),
}
}
fn build_http_connect_request(
host: &str,
port: u16,
username: &str,
password: &str,
include_probe_headers: bool,
) -> Vec<u8> {
let target = format_http_authority(host, port);
let mut request = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n");
if include_probe_headers {
request.push_str("User-Agent: Mozilla/5.0\r\nProxy-Connection: Keep-Alive\r\n");
}
if !username.is_empty() || !password.is_empty() {
let token = BASE64.encode(format!("{username}:{password}"));
request.push_str(&format!("Proxy-Authorization: Basic {token}\r\n"));
}
request.push_str("\r\n");
request.into_bytes()
}
fn build_socks5_connect_request(host: &str, port: u16) -> Result<Vec<u8>, ()> {
let host = unbracket_host(host);
let mut request = vec![0x05, 0x01, 0x00];
match host.parse::<IpAddr>() {
Ok(IpAddr::V4(address)) => {
request.push(0x01);
request.extend_from_slice(&address.octets());
}
Ok(IpAddr::V6(address)) => {
request.push(0x04);
request.extend_from_slice(&address.octets());
}
Err(_) => {
let host = host.as_bytes();
if host.len() > u8::MAX as usize {
return Err(());
}
request.extend_from_slice(&[0x03, host.len() as u8]);
request.extend_from_slice(host);
}
}
request.extend_from_slice(&port.to_be_bytes());
Ok(request)
}
async fn socks5_authenticate(stream: &mut TcpStream, proxy: &ProxyEndpoint) -> Result<(), String> {
let username = proxy.username.as_bytes();
let password = proxy.password.as_bytes();
@ -233,11 +272,614 @@ async fn socks5_authenticate(stream: &mut TcpStream, proxy: &ProxyEndpoint) -> R
}
}
// ---------------------------------------------------------------------------
// Retry helpers for proxy endpoint testing
// ---------------------------------------------------------------------------
// These wrap tokio read/write with ENOTCONN/WouldBlock retry logic,
// which is needed on macOS where async connect can resolve before the
// TCP handshake is fully complete.
async fn write_all_retry(stream: &mut TcpStream, data: &[u8]) -> Result<(), String> {
use tokio::io::AsyncWriteExt;
loop {
match stream.write_all(data).await {
Ok(()) => return Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotConnected || e.kind() == std::io::ErrorKind::WouldBlock => {
stream.writable().await.map_err(|e| format!("writable wait failed: {e}"))?;
}
Err(e) => return Err(format!("write failed: {e}")),
}
}
}
async fn read_with_retry(stream: &mut TcpStream, buf: &mut [u8]) -> Result<usize, String> {
use tokio::io::AsyncReadExt;
loop {
match stream.read(buf).await {
Ok(n) => return Ok(n),
Err(e) if e.kind() == std::io::ErrorKind::NotConnected || e.kind() == std::io::ErrorKind::WouldBlock => {
stream.readable().await.map_err(|e| format!("readable wait failed: {e}"))?;
}
Err(e) => return Err(format!("read failed: {e}")),
}
}
}
async fn read_exact_with_retry(stream: &mut TcpStream, buf: &mut [u8]) -> Result<(), String> {
let mut offset = 0;
while offset < buf.len() {
let n = read_with_retry(stream, &mut buf[offset..]).await?;
if n == 0 {
return Err("connection closed".to_string());
}
offset += n;
}
Ok(())
}
fn find_http_header_end(response: &[u8]) -> Option<usize> {
let crlf_end = response.windows(4).position(|window| window == b"\r\n\r\n").map(|pos| pos + 4);
let lf_end = response.windows(2).position(|window| window == b"\n\n").map(|pos| pos + 2);
match (crlf_end, lf_end) {
(Some(crlf), Some(lf)) => Some(crlf.min(lf)),
(Some(end), None) | (None, Some(end)) => Some(end),
(None, None) => None,
}
}
fn parse_http_status_code(header: &[u8]) -> Result<u16, String> {
let text = String::from_utf8_lossy(header);
let first_line = text.lines().next().unwrap_or("");
let mut parts = first_line.splitn(3, ' ');
let version = parts.next().unwrap_or("");
let status = parts.next().unwrap_or("");
if version != "HTTP/1.0" && version != "HTTP/1.1" {
return Err(format!("HTTP proxy CONNECT failed: {first_line}"));
}
status.parse::<u16>().map_err(|_| format!("HTTP proxy CONNECT failed: {first_line}"))
}
async fn read_http_connect_response(stream: &mut TcpStream) -> Result<Vec<u8>, String> {
let mut response = Vec::with_capacity(512);
let mut byte = [0_u8; 1];
let mut interim_responses = 0;
loop {
if let Some(end) = find_http_header_end(&response) {
let status = parse_http_status_code(&response[..end])?;
if (100..200).contains(&status) {
interim_responses += 1;
if interim_responses > MAX_HTTP_INTERIM_RESPONSES {
return Err("Proxy response is incomplete or malformed".to_string());
}
response.clear();
continue;
}
response.truncate(end);
return Ok(response);
}
if response.len() >= MAX_HTTP_RESPONSE_SIZE {
return Err("Proxy response is incomplete or malformed".to_string());
}
let n = stream.read(&mut byte).await.map_err(|e| format!("Failed to read CONNECT response: {e}"))?;
if n == 0 {
return Ok(response);
}
response.push(byte[0]);
}
}
async fn read_http_response_with_retry(stream: &mut TcpStream, max_size: usize) -> Result<Vec<u8>, String> {
let mut response = Vec::with_capacity(max_size.min(4096));
let mut buf = [0u8; 4096];
let mut interim_responses = 0;
loop {
if let Some(end) = find_http_header_end(&response) {
let status = parse_http_status_code(&response[..end])?;
if (100..200).contains(&status) {
interim_responses += 1;
if interim_responses > MAX_HTTP_INTERIM_RESPONSES {
return Err("Proxy response is incomplete or malformed".to_string());
}
response.drain(..end);
continue;
}
// A proxy can start the tunneled protocol in the same TCP read.
// Only the final HTTP header belongs to the CONNECT handshake.
response.truncate(end);
return Ok(response);
}
if response.len() >= max_size {
return Ok(response);
}
let remaining = max_size - response.len();
let to_read = buf.len().min(remaining);
let n = read_with_retry(stream, &mut buf[..to_read]).await?;
if n == 0 {
return Ok(response);
}
response.extend_from_slice(&buf[..n]);
}
}
// ---------------------------------------------------------------------------
// Parse helpers for HTTP CONNECT and SOCKS5 CONNECT responses.
// These are pure functions (no I/O), testable without a running proxy.
// ---------------------------------------------------------------------------
/// Parse an HTTP CONNECT response, validating HTTP version and 2xx status.
///
/// Rejects truncated responses, handles interim 1xx responses by parsing the
/// final response, tolerates LF-only line endings, and ignores tunneled bytes
/// that follow the final header in the same read.
fn parse_http_connect_response(response: &[u8]) -> Result<String, String> {
let mut remaining = response;
let mut interim_responses = 0;
loop {
let Some(end) = find_http_header_end(remaining) else {
return Err("Proxy response is incomplete or malformed".to_string());
};
if end > MAX_HTTP_RESPONSE_SIZE {
return Err("Proxy response is incomplete or malformed".to_string());
}
let code = parse_http_status_code(&remaining[..end])?;
if (100..200).contains(&code) {
interim_responses += 1;
if interim_responses > MAX_HTTP_INTERIM_RESPONSES {
return Err("Proxy response is incomplete or malformed".to_string());
}
remaining = &remaining[end..];
continue;
}
if (200..300).contains(&code) {
return Ok(format!("HTTP CONNECT proxy connection successful ({code})"));
}
return Err(format!("HTTP proxy CONNECT failed: HTTP {code}"));
}
}
/// Validate a SOCKS5 CONNECT reply header (first 4 bytes).
fn parse_socks5_connect_header(header: &[u8; 4]) -> Result<(), String> {
if header[0] != 0x05 {
return Err(format!("Invalid SOCKS proxy version: {}", header[0]));
}
if header[1] != 0x00 {
return Err(format!("SOCKS proxy connect rejected (code {})", header[1]));
}
Ok(())
}
/// Parse a `test_target` string (`host:port` or `[ipv6]:port`) into `(String, u16)`.
fn parse_test_target(target: &str) -> Result<(String, u16), String> {
// IPv6: [fe80::1]:7890 -> split on ']:', strip brackets
if let Some(rest) = target.strip_prefix('[') {
let Some((inner, port_str)) = rest.split_once("]:") else {
return Err("Invalid test target, expected host:port or [ipv6]:port".to_string());
};
let port: u16 = port_str.parse().map_err(|_| "Invalid test target port".to_string())?;
Ok((inner.to_string(), port))
} else {
let (host_str, port_str) = target
.split_once(':')
.ok_or_else(|| "Invalid test target, expected host:port or [ipv6]:port".to_string())?;
if host_str.is_empty() || port_str.is_empty() {
return Err("Invalid test target, expected host:port or [ipv6]:port".to_string());
}
let port: u16 = port_str.parse().map_err(|_| "Invalid test target port".to_string())?;
Ok((host_str.to_string(), port))
}
}
/// Test a proxy endpoint by performing a full HTTP CONNECT or SOCKS5
/// handshake. When `test_target` is `Some(host:port)` the probe connects
/// to that target (full tunnel test). When `None` the probe performs an
/// endpoint-only liveness check that exercises auth but requires no
/// external destination.
pub async fn test_proxy_endpoint(
proxy_type: ProxyType,
host: &str,
port: u16,
username: &str,
password: &str,
test_target: Option<&str>,
) -> Result<String, String> {
let start = Instant::now();
// Strip brackets if user typed IPv6 as [fe80::1]
let host = host.trim_start_matches('[').trim_end_matches(']');
let mut stream = timeout(CONNECT_TIMEOUT, TcpStream::connect((host, port)))
.await
.map_err(|_| format!("Proxy connection timed out ({:?})", CONNECT_TIMEOUT))?
.map_err(|e| format!("Failed to connect to proxy: {e}"))?;
let handshake_result = timeout(CONNECT_TIMEOUT, async {
match proxy_type {
ProxyType::Http => {
let connect_target = match test_target.filter(|t| !t.is_empty()) {
Some(target) => {
let (th, tp) = parse_test_target(target)?;
(th, tp)
}
None => {
// Endpoint-only: TCP reachability already verified above.
// No CONNECT is sent — this avoids destination/ACL
// dependency per RFC 9110 §9.3.6.
let elapsed = start.elapsed();
return Ok(format!("Proxy reachable on {host}:{port} — endpoint check only ({elapsed:?})"));
}
};
let (target_host, target_port) = connect_target;
let target_authority = format_http_authority(&target_host, target_port);
let request = build_http_connect_request(&target_host, target_port, username, password, true);
write_all_retry(&mut stream, &request).await?;
let response = read_http_response_with_retry(&mut stream, 8192).await?;
let msg = parse_http_connect_response(&response)?;
let elapsed = start.elapsed();
Ok(format!("{msg}{target_authority} ({elapsed:?})"))
}
ProxyType::Socks5 => {
let wants_auth = !username.is_empty() || !password.is_empty();
let methods: &[u8] = if wants_auth { &[0x00, 0x02] } else { &[0x00] };
let mut hello = vec![0x05, methods.len() as u8];
hello.extend_from_slice(methods);
write_all_retry(&mut stream, &hello).await?;
let mut method = [0u8; 2];
read_exact_with_retry(&mut stream, &mut method).await?;
if method[0] != 0x05 {
return Err(format!("Invalid SOCKS proxy version: {}", method[0]));
}
let mut auth_succeeded = false;
match method[1] {
0x00 => {}
0x02 => {
let u = username.as_bytes();
let p = password.as_bytes();
if u.len() > u8::MAX as usize || p.len() > u8::MAX as usize {
return Err("SOCKS username or password is too long".to_string());
}
let mut req = vec![0x01, u.len() as u8];
req.extend_from_slice(u);
req.push(p.len() as u8);
req.extend_from_slice(p);
write_all_retry(&mut stream, &req).await?;
let mut res = [0u8; 2];
read_exact_with_retry(&mut stream, &mut res).await?;
if res != [0x01, 0x00] {
return Err("SOCKS proxy authentication failed".to_string());
}
auth_succeeded = true;
}
0xff => return Err("SOCKS proxy rejected all supported auth methods".to_string()),
other => return Err(format!("SOCKS proxy selected unsupported auth method: {other}")),
}
// CONNECT (full tunnel test) only when test_target is provided.
// Otherwise the method/auth negotiation above is sufficient
// for an endpoint-only reachability check.
if let Some(target) = test_target.filter(|t| !t.is_empty()) {
let (target_host, target_port) = parse_test_target(target)?;
let req = build_socks5_connect_request(&target_host, target_port)
.map_err(|_| "Proxy target host too long for SOCKS5 domain address".to_string())?;
write_all_retry(&mut stream, &req).await?;
let mut head = [0u8; 4];
read_exact_with_retry(&mut stream, &mut head).await?;
parse_socks5_connect_header(&head)?;
// Discard remaining bound address bytes
let addr_len = match head[3] {
0x01 => 4,
0x03 => {
let mut len = [0u8; 1];
read_exact_with_retry(&mut stream, &mut len).await?;
len[0] as usize
}
0x04 => 16,
other => return Err(format!("Unsupported SOCKS bound address type: {other}")),
};
let mut discard = vec![0u8; addr_len + 2];
read_exact_with_retry(&mut stream, &mut discard).await?;
let elapsed = start.elapsed();
Ok(format!("SOCKS5 proxy connection successful — {target_host}:{target_port} ({elapsed:?})"))
} else {
// Endpoint-only: auth verified, no CONNECT sent.
let auth_note = if auth_succeeded { " — auth verified" } else { "" };
let elapsed = start.elapsed();
Ok(format!("SOCKS5 proxy reachable on {host}:{port}{auth_note} ({elapsed:?})"))
}
}
}
})
.await;
match handshake_result {
Ok(Ok(msg)) => Ok(msg),
Ok(Err(e)) => Err(format!("Proxy handshake failed ({:?}): {e}", start.elapsed())),
Err(_) => Err(format!("Proxy handshake timed out ({:?})", CONNECT_TIMEOUT)),
}
}
#[cfg(test)]
mod tests {
use super::ProxyTunnelManager;
use super::*;
use crate::models::connection::ProxyType;
// ── HTTP CONNECT response parsing ──────────────────────────────────────
#[test]
fn parse_http_success_http11() {
let resp = parse_http_connect_response(b"HTTP/1.1 200 Connection Established\r\n\r\n");
assert!(resp.is_ok(), "HTTP/1.1 200 should be success, got: {resp:?}");
assert!(resp.unwrap().contains("200"));
}
#[test]
fn parse_http_success_http10() {
let resp = parse_http_connect_response(b"HTTP/1.0 200 OK\r\n\r\n");
assert!(resp.is_ok(), "HTTP/1.0 200 should be success, got: {resp:?}");
}
#[test]
fn parse_http_error_status() {
let resp = parse_http_connect_response(b"HTTP/1.1 502 Bad Gateway\r\n\r\n");
assert!(resp.is_err(), "502 should be error");
assert!(resp.unwrap_err().contains("502"), "error should mention 502");
}
#[test]
fn parse_http_malformed_garbage() {
// No HTTP status line and missing terminator — rejected as incomplete.
let resp = parse_http_connect_response(b"garbage response line");
assert!(resp.is_err(), "garbage should be error");
assert!(resp.unwrap_err().contains("incomplete"), "should mention incomplete");
}
#[test]
fn parse_http_empty_response() {
let resp = parse_http_connect_response(b"");
assert!(resp.is_err(), "empty should be error");
}
#[test]
fn parse_http_bad_version() {
let resp = parse_http_connect_response(b"HTTP/2.0 200 OK\r\n\r\n");
assert!(resp.is_err(), "HTTP/2.0 should be rejected");
}
#[test]
fn parse_http_bad_version_malformed_digit() {
// HTTP-version = HTTP-name "/" DIGIT "." DIGIT (RFC 9112 §3.2)
let resp = parse_http_connect_response(b"HTTP/1.x 200 OK\r\n\r\n");
assert!(resp.is_err(), "HTTP/1.x should be rejected");
}
#[test]
fn parse_http_truncated_missing_terminator() {
// Response without \r\n\r\n terminator — truncated/malformed.
let resp = parse_http_connect_response(b"HTTP/1.1 200 OK");
assert!(resp.is_err(), "truncated should be error");
assert!(resp.unwrap_err().contains("incomplete"), "should mention incomplete");
}
#[test]
fn parse_http_truncated_lf_only() {
// LF-only line ending without double-\n terminator.
let resp = parse_http_connect_response(b"HTTP/1.1 200 OK\n");
assert!(resp.is_err(), "truncated LF-only should be error");
}
#[test]
fn parse_http_continue_then_success() {
// 100 Continue followed by the real 200 response.
let resp =
parse_http_connect_response(b"HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 Connection Established\r\n\r\n");
assert!(resp.is_ok(), "100 Continue + 200 should be success, got: {resp:?}");
assert!(resp.unwrap().contains("200"));
}
#[test]
fn parse_http_continue_with_headers_then_success() {
// 100 Continue with extra headers followed by 200.
let resp =
parse_http_connect_response(b"HTTP/1.1 100 Continue\r\nServer: Proxy\r\n\r\nHTTP/1.1 200 OK\r\n\r\n");
assert!(resp.is_ok(), "100 Continue with headers + 200 should be success");
}
#[test]
fn parse_http_continue_only() {
// Just 100 Continue and nothing else — incomplete.
let resp = parse_http_connect_response(b"HTTP/1.1 100 Continue\r\n\r\n");
assert!(resp.is_err(), "100 Continue alone should be error");
}
#[test]
fn parse_http_auth_challenge_407() {
// 407 Proxy Authentication Required.
let resp =
parse_http_connect_response(b"HTTP/1.1 407 Proxy Auth Required\r\nProxy-Authenticate: Basic\r\n\r\n");
assert!(resp.is_err(), "407 should be error");
assert!(resp.unwrap_err().contains("407"), "error should mention 407");
}
#[test]
fn parse_http_oversized_response() {
// Response exceeding 8192 bytes.
let mut oversized = b"HTTP/1.1 200 OK\r\n".to_vec();
oversized.resize(8193, b'X');
let resp = parse_http_connect_response(&oversized);
assert!(resp.is_err(), "oversized should be error");
assert!(resp.unwrap_err().contains("incomplete"), "should mention incomplete");
}
#[test]
fn parse_http_lf_only_terminator() {
// LF-only line endings with \n\n terminator (RFC 7230 §3.5 tolerance).
let resp = parse_http_connect_response(b"HTTP/1.1 200 OK\n\n");
assert!(resp.is_ok(), "LF-only with double-LF terminator should be success");
}
#[test]
fn parse_http_success_ignores_immediate_tunneled_payload() {
let resp = parse_http_connect_response(b"HTTP/1.1 200 OK\r\n\r\n\x16\x03\x01\x00\x2a");
assert!(resp.is_ok(), "binary payload after final header should be ignored");
}
#[test]
fn http_request_preserves_hostname_ipv4_and_ipv6() {
assert_eq!(
build_http_connect_request("db.example.com", 5432, "", "", false),
b"CONNECT db.example.com:5432 HTTP/1.1\r\nHost: db.example.com:5432\r\n\r\n"
);
assert_eq!(
build_http_connect_request("192.0.2.10", 5432, "", "", false),
b"CONNECT 192.0.2.10:5432 HTTP/1.1\r\nHost: 192.0.2.10:5432\r\n\r\n"
);
assert_eq!(
build_http_connect_request("2001:db8::10", 5432, "", "", false),
b"CONNECT [2001:db8::10]:5432 HTTP/1.1\r\nHost: [2001:db8::10]:5432\r\n\r\n"
);
assert_eq!(
build_http_connect_request("[2001:db8::10]", 5432, "", "", false),
b"CONNECT [2001:db8::10]:5432 HTTP/1.1\r\nHost: [2001:db8::10]:5432\r\n\r\n"
);
}
#[test]
fn socks5_request_preserves_hostname_ipv4_and_ipv6() {
assert_eq!(
build_socks5_connect_request("db.example.com", 5432).unwrap(),
[vec![0x05, 0x01, 0x00, 0x03, 14], b"db.example.com".to_vec(), 5432_u16.to_be_bytes().to_vec(),].concat()
);
assert_eq!(
build_socks5_connect_request("192.0.2.10", 5432).unwrap(),
vec![0x05, 0x01, 0x00, 0x01, 192, 0, 2, 10, 0x15, 0x38]
);
assert_eq!(
build_socks5_connect_request("2001:db8::10", 5432).unwrap(),
vec![
0x05, 0x01, 0x00, 0x04, 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x10, 0x15, 0x38,
]
);
}
#[tokio::test]
async fn runtime_http_connect_preserves_same_read_payload() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy_port = listener.local_addr().unwrap().port();
let payload = b"\x16\x03\x01\x00\x2a";
let mock = tokio::spawn(async move {
let (mut connection, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buf = [0_u8; 256];
while find_http_header_end(&request).is_none() {
let n = connection.read(&mut buf).await.unwrap();
assert_ne!(n, 0, "CONNECT request should be complete");
request.extend_from_slice(&buf[..n]);
}
assert!(request.starts_with(b"CONNECT db.example.com:5432 HTTP/1.1\r\nHost: db.example.com:5432\r\n"));
connection.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n\x16\x03\x01\x00\x2a").await.unwrap();
});
let stream = TcpStream::connect(("127.0.0.1", proxy_port)).await.unwrap();
let proxy = ProxyEndpoint {
proxy_type: ProxyType::Http,
host: "127.0.0.1".to_string(),
port: proxy_port,
username: String::new(),
password: String::new(),
};
let remote = RemoteEndpoint { host: "db.example.com".to_string(), port: 5432 };
let mut tunneled = http_connect(stream, &proxy, &remote).await.unwrap();
let mut actual_payload = [0_u8; 5];
tunneled.read_exact(&mut actual_payload).await.unwrap();
mock.await.unwrap();
assert_eq!(&actual_payload, payload);
}
// ── test_target parsing ───────────────────────────────────────────────
#[test]
fn parse_test_target_ipv4() {
let (host, port) = parse_test_target("192.168.1.1:8080").unwrap();
assert_eq!(host, "192.168.1.1");
assert_eq!(port, 8080);
}
#[test]
fn parse_test_target_ipv6() {
let (host, port) = parse_test_target("[fe80::1]:7890").unwrap();
assert_eq!(host, "fe80::1");
assert_eq!(port, 7890);
}
#[test]
fn parse_test_target_hostname() {
let (host, port) = parse_test_target("proxy.example.com:3128").unwrap();
assert_eq!(host, "proxy.example.com");
assert_eq!(port, 3128);
}
#[test]
fn parse_test_target_missing_port() {
let err = parse_test_target("192.168.1.1").unwrap_err();
assert!(err.contains("Invalid test target"), "should mention invalid");
}
#[test]
fn parse_test_target_empty_host() {
let err = parse_test_target(":8080").unwrap_err();
assert!(err.contains("Invalid test target"), "should mention invalid");
}
#[test]
fn parse_test_target_bad_port() {
let err = parse_test_target("host:badport").unwrap_err();
assert!(err.contains("port"), "should mention port");
}
#[test]
fn parse_test_target_bad_ipv6_missing_bracket() {
let err = parse_test_target("[fe80::1:7890").unwrap_err();
assert!(err.contains("Invalid test target"), "malformed IPv6 should fail");
}
// ── SOCKS5 CONNECT header parsing ─────────────────────────────────────
#[test]
fn parse_socks5_header_success() {
let result = parse_socks5_connect_header(&[0x05, 0x00, 0x00, 0x01]);
assert!(result.is_ok(), "0x00 reply should be success");
}
#[test]
fn parse_socks5_header_rejected() {
let result = parse_socks5_connect_header(&[0x05, 0x03, 0x00, 0x01]);
assert!(result.is_err(), "code 0x03 should be error");
assert!(result.unwrap_err().contains("rejected"), "error should mention rejected");
}
#[test]
fn parse_socks5_header_bad_version() {
let result = parse_socks5_connect_header(&[0x04, 0x00, 0x00, 0x01]);
assert!(result.is_err(), "version 4 should be error");
assert!(result.unwrap_err().contains("version"), "error should mention version");
}
// ── Existing tunnel lifecycle tests ────────────────────────────────────
#[tokio::test]
async fn start_tunnel_reuses_existing_local_port() {
let manager = ProxyTunnelManager::new();
@ -256,4 +898,132 @@ mod tests {
manager.stop_tunnel("connection").await;
}
// ── I/O-layer 1xx response handling ────────────────────────────────────
#[tokio::test]
async fn read_http_1xx_then_final_in_separate_writes() {
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let port = addr.port();
// Mock proxy: sends 100 Continue and 200 OK in separate writes
let mock = tokio::spawn(async move {
let (mut conn, _) = listener.accept().await.unwrap();
// Consume the CONNECT request
let mut buf = [0u8; 4096];
loop {
let n = conn.read(&mut buf).await.unwrap();
if buf[..n].windows(4).any(|w| w == b"\r\n\r\n") || n == 0 {
break;
}
}
// Write 100 Continue
conn.write_all(b"HTTP/1.1 100 Continue\r\nServer: test\r\n\r\n").await.unwrap();
// Small delay to encourage separate TCP segments
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
// Write 200 OK
conn.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n").await.unwrap();
// Hold connection open until the test finishes
let _ = tokio::time::sleep(std::time::Duration::from_millis(200)).await;
});
let result = test_proxy_endpoint(ProxyType::Http, "127.0.0.1", port, "", "", Some("example.com:443")).await;
mock.await.unwrap();
assert!(result.is_ok(), "should succeed with 100+200 in separate writes, got: {result:?}");
assert!(result.unwrap().contains("example.com:443"), "should mention test target");
}
#[tokio::test]
async fn read_http_1xx_then_final_in_same_write() {
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
// Mock proxy: sends 100 Continue + 200 OK in a single write
let mock = tokio::spawn(async move {
let (mut conn, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
loop {
let n = conn.read(&mut buf).await.unwrap();
if buf[..n].windows(4).any(|w| w == b"\r\n\r\n") || n == 0 {
break;
}
}
conn.write_all(b"HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 Connection Established\r\n\r\n\x16\x03\x01")
.await
.unwrap();
let _ = tokio::time::sleep(std::time::Duration::from_millis(200)).await;
});
let result = test_proxy_endpoint(ProxyType::Http, "127.0.0.1", port, "", "", Some("example.com:443")).await;
mock.await.unwrap();
assert!(result.is_ok(), "should succeed with 100+200 in same write, got: {result:?}");
assert!(result.unwrap().contains("200"), "should mention 200 status");
}
#[tokio::test]
async fn read_http_lf_only_response_with_connection_held_open() {
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
// Mock proxy: LF-only 200 OK, connection stays open (no immediate close)
let mock = tokio::spawn(async move {
let (mut conn, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
loop {
let n = conn.read(&mut buf).await.unwrap();
if buf[..n].windows(4).any(|w| w == b"\r\n\r\n") || n == 0 {
break;
}
}
conn.write_all(b"HTTP/1.1 200 OK\n\n").await.unwrap();
// Connection stays open — the reader must detect \n\n and not time out
let _ = tokio::time::sleep(std::time::Duration::from_millis(300)).await;
});
let result = test_proxy_endpoint(ProxyType::Http, "127.0.0.1", port, "", "", Some("example.com:443")).await;
mock.await.unwrap();
assert!(result.is_ok(), "LF-only 200 should succeed, got: {result:?}");
}
#[tokio::test]
async fn socks5_endpoint_check_no_auth_claimed_when_server_selects_method_zero() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
// SOCKS5 server that selects method 0x00 (no auth) despite credentials offered
let mock = tokio::spawn(async move {
let (mut conn, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 256];
let n = conn.read(&mut buf).await.unwrap();
assert!(buf[..n].contains(&0x02), "should offer username/password method");
// Select method 0x00 — no authentication
conn.write_all(&[0x05, 0x00]).await.unwrap();
let _ = tokio::time::sleep(std::time::Duration::from_millis(100)).await;
});
let result = test_proxy_endpoint(ProxyType::Socks5, "127.0.0.1", port, "user", "pass", None).await;
mock.await.unwrap();
assert!(result.is_ok(), "should reach proxy, got: {result:?}");
assert!(
!result.unwrap().contains("auth verified"),
"should NOT claim auth verified when server selected method 0x00"
);
}
}

View File

@ -276,6 +276,7 @@ mod tests {
port,
username: String::new(),
password: String::new(),
test_target: None,
})
}

View File

@ -338,6 +338,11 @@ pub struct ProxyTunnelConfig {
pub username: String,
#[serde(default)]
pub password: String,
/// Optional target for tunnel profile testing. When set, the test connects
/// to this `host:port`; when empty, the test performs an endpoint-only
/// liveness probe that requires no external destination.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub test_target: Option<String>,
/// See [`SshTunnelConfig::profile_id`].
#[serde(default, skip_serializing_if = "String::is_empty")]
pub profile_id: String,
@ -2348,6 +2353,7 @@ mod tests {
port: 1080,
username: String::new(),
password: String::new(),
test_target: None,
})];
let saved = serde_json::to_value(config).unwrap();

View File

@ -556,6 +556,7 @@ mod tests {
port: 1080,
username: String::new(),
password: String::new(),
test_target: None,
profile_id: String::new(),
}));
let proxy_error = connection_final_proxy_port(