From 140db73ef400cce3298d84ec91b23420d7fdded5 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 26 Feb 2026 15:21:36 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20fix(startup-release):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Win/mac=20=E5=8F=91=E5=B8=83=E5=8C=85?= =?UTF-8?q?=E7=99=BD=E5=B1=8F=E4=B8=8E=E6=97=A0=E5=93=8D=E5=BA=94=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 v0.4.7 引入的高风险 chunk 拆分配置 - 恢复 main.tsx 的 Monaco 稳定初始化方式 - 调整 release workflow 的 macOS codesign 参数避免双击无反应 --- .github/workflows/release.yml | 4 +- frontend/src/main.tsx | 16 +---- frontend/vite.config.ts | 106 +--------------------------------- 3 files changed, 5 insertions(+), 121 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01ae239..3f89c37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -203,7 +203,9 @@ jobs: APP_NAME=$(basename "$APP_PATH") echo "🔏 正在进行 Ad-hoc 签名..." - codesign --force --options runtime --deep --sign - "$APP_NAME" + # 注意:Ad-hoc + hardened runtime(--options runtime)在未配置 entitlements 时, + # 可能导致部分 macOS 机型上应用双击无响应。这里保持 Ad-hoc 深签名但禁用 runtime hardened。 + codesign --force --deep --sign - "$APP_NAME" DMG_NAME="${{ matrix.build_name }}.dmg" FINAL_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg" diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 4b67fcd..9457771 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,21 +6,7 @@ import App from './App' // 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。 // Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。 import { loader } from '@monaco-editor/react' -import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' -import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker' -import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker.js?worker' -import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js' -import 'monaco-editor/esm/vs/language/json/monaco.contribution.js' - -(self as any).MonacoEnvironment = { - getWorker(_: unknown, label: string) { - if (label === 'json') { - return new JsonWorker() - } - return new EditorWorker() - }, -} - +import * as monaco from 'monaco-editor' loader.config({ monaco }) // 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义 diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 73a0e55..4b6e91a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,54 +1,6 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -const normalizeModuleId = (id: string): string => id.replace(/\\/g, '/') - -const sanitizeChunkToken = (raw: string): string => - String(raw || '') - .trim() - .replace(/[^a-zA-Z0-9_-]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') || 'misc' - -const firstSegmentAfter = (id: string, marker: string): string => { - const idx = id.indexOf(marker) - if (idx < 0) return '' - const rest = id.substring(idx + marker.length) - const [segment] = rest.split('/') - return sanitizeChunkToken(segment) -} - -const resolveMonacoChunk = (id: string, prefix: string): string | undefined => { - if (!id.includes('/node_modules/monaco-editor/')) return undefined - - if (id.includes('/esm/vs/language/typescript/')) { - if (id.includes('typescriptServices')) return `${prefix}-ts-services` - return `${prefix}-typescript` - } - if (id.includes('/esm/vs/language/json/')) return `${prefix}-json` - if (id.includes('/esm/vs/language/css/')) return `${prefix}-css` - if (id.includes('/esm/vs/language/html/')) return `${prefix}-html` - - if (id.includes('/esm/vs/editor/contrib/')) { - return `${prefix}-editor-contrib-${firstSegmentAfter(id, '/esm/vs/editor/contrib/')}` - } - if (id.includes('/esm/vs/editor/browser/')) { - return `${prefix}-editor-browser-${firstSegmentAfter(id, '/esm/vs/editor/browser/')}` - } - if (id.includes('/esm/vs/editor/common/')) { - return `${prefix}-editor-common-${firstSegmentAfter(id, '/esm/vs/editor/common/')}` - } - if (id.includes('/esm/vs/editor/')) return `${prefix}-editor` - - if (id.includes('/esm/vs/base/browser/')) return `${prefix}-base-browser` - if (id.includes('/esm/vs/base/common/')) return `${prefix}-base-common` - if (id.includes('/esm/vs/base/')) return `${prefix}-base` - - if (id.includes('/esm/vs/platform/')) return `${prefix}-platform` - - return `${prefix}-misc` -} - // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], @@ -59,61 +11,5 @@ export default defineConfig({ build: { outDir: 'dist', // Standard Wails output directory emptyOutDir: true, - rollupOptions: { - output: { - manualChunks(id) { - const moduleId = normalizeModuleId(id) - if (!moduleId.includes('node_modules')) return undefined - - const monacoChunk = resolveMonacoChunk(moduleId, 'vendor-monaco') - if (monacoChunk) { - return monacoChunk - } - if (moduleId.includes('/node_modules/@monaco-editor/react/')) return 'vendor-monaco-react' - - if (moduleId.includes('/node_modules/antd/es/')) { - return `vendor-antd-${firstSegmentAfter(moduleId, '/node_modules/antd/es/')}` - } - if (moduleId.includes('/node_modules/antd/')) return 'vendor-antd' - if (moduleId.includes('/node_modules/@ant-design/icons/')) return 'vendor-antd-icons' - if (moduleId.includes('/node_modules/@ant-design/cssinjs/')) return 'vendor-antd-css' - if (moduleId.includes('/node_modules/rc-')) return 'vendor-antd-rc' - - if (moduleId.includes('/node_modules/@dnd-kit/')) return 'vendor-dnd-kit' - if (moduleId.includes('/node_modules/sql-formatter/')) return 'vendor-sql-formatter' - - if ( - moduleId.includes('/node_modules/react/') - || moduleId.includes('/node_modules/react-dom/') - || moduleId.includes('/node_modules/scheduler/') - ) { - return 'vendor-react' - } - - if ( - moduleId.includes('/node_modules/zustand/') - || moduleId.includes('/node_modules/uuid/') - || moduleId.includes('/node_modules/clsx/') - || moduleId.includes('/node_modules/react-resizable/') - ) { - return 'vendor-utils' - } - - return 'vendor-misc' - }, - }, - }, - }, - worker: { - format: 'es', - rollupOptions: { - output: { - manualChunks(id) { - const moduleId = normalizeModuleId(id) - if (!moduleId.includes('node_modules')) return undefined - return resolveMonacoChunk(moduleId, 'worker-monaco') - }, - }, - }, - }, + } }) From d0ba8822f3ad7c80b80dd3818d2f6a19ac076785 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Feb 2026 08:37:35 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20feat(driver-manager):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=A9=B1=E5=8A=A8=E5=A4=9A=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E4=B8=8E=E7=89=88=E6=9C=AC=E7=BA=A7=E5=8C=85?= =?UTF-8?q?=E5=A4=A7=E5=B0=8F=E5=8A=A8=E6=80=81=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增驱动版本列表能力,支持按版本选择安装 - 新增按版本查询安装包大小接口,前端切换版本后动态刷新 - 增加版本大小查询回退策略(tag 未命中时回退 latest) - 优化版本下拉加载链路并增加后台预热,降低首次展开等待 --- docs/driver-manifest.json | 26 +- .../src/components/DriverManagerModal.tsx | 296 ++++- frontend/wailsjs/go/app/App.d.ts | 6 +- frontend/wailsjs/go/app/App.js | 12 +- go.mod | 2 +- internal/app/methods_driver.go | 1055 ++++++++++++++++- 6 files changed, 1330 insertions(+), 67 deletions(-) diff --git a/docs/driver-manifest.json b/docs/driver-manifest.json index ae4b5c9..a1c0c7c 100644 --- a/docs/driver-manifest.json +++ b/docs/driver-manifest.json @@ -3,79 +3,79 @@ "drivers": { "mariadb": { "engine": "go", - "version": "go-embedded", + "version": "1.9.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/mariadb" }, "diros": { "engine": "go", - "version": "go-embedded", + "version": "1.9.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/diros" }, "sphinx": { "engine": "go", - "version": "go-embedded", + "version": "1.9.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sphinx" }, "sqlserver": { "engine": "go", - "version": "go-embedded", + "version": "1.9.6", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sqlserver" }, "sqlite": { "engine": "go", - "version": "go-embedded", + "version": "1.44.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sqlite" }, "duckdb": { "engine": "go", - "version": "go-embedded", + "version": "2.5.5", "checksumPolicy": "off", "downloadUrl": "builtin://activate/duckdb" }, "dameng": { "engine": "go", - "version": "go-embedded", + "version": "1.8.22", "checksumPolicy": "off", "downloadUrl": "builtin://activate/dameng" }, "kingbase": { "engine": "go", - "version": "go-embedded", + "version": "0.0.0-20201021123113-29bd62a876c3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/kingbase" }, "highgo": { "engine": "go", - "version": "go-embedded", + "version": "0.0.0-local", "checksumPolicy": "off", "downloadUrl": "builtin://activate/highgo" }, "vastbase": { "engine": "go", - "version": "go-embedded", + "version": "1.11.1", "checksumPolicy": "off", "downloadUrl": "builtin://activate/vastbase" }, "mongodb": { "engine": "go", - "version": "go-embedded", + "version": "2.5.0", "checksumPolicy": "off", "downloadUrl": "builtin://activate/mongodb" }, "tdengine": { "engine": "go", - "version": "go-embedded", + "version": "3.7.8", "checksumPolicy": "off", "downloadUrl": "builtin://activate/tdengine" }, "postgres": { "engine": "go", - "version": "go-embedded", + "version": "1.11.1", "checksumPolicy": "off", "downloadUrl": "builtin://activate/postgres" } diff --git a/frontend/src/components/DriverManagerModal.tsx b/frontend/src/components/DriverManagerModal.tsx index a36d412..6bbadd3 100644 --- a/frontend/src/components/DriverManagerModal.tsx +++ b/frontend/src/components/DriverManagerModal.tsx @@ -1,9 +1,11 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Button, Modal, Progress, Space, Table, Tag, Typography, message } from 'antd'; +import { Button, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd'; import { DeleteOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { DownloadDriverPackage, + GetDriverVersionList, + GetDriverVersionPackageSize, GetDriverStatusList, RemoveDriverPackage, } from '../../wailsjs/go/app/App'; @@ -14,6 +16,8 @@ type DriverStatusRow = { type: string; name: string; builtIn: boolean; + pinnedVersion?: string; + installedVersion?: string; packageSizeText?: string; runtimeAvailable: boolean; packageInstalled: boolean; @@ -35,12 +39,75 @@ type ProgressState = { percent: number; }; +type DriverVersionOption = { + version: string; + downloadUrl: string; + packageSizeText?: string; + recommended?: boolean; + source?: string; + year?: string; + displayLabel?: string; +}; + +const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version}@@${option.downloadUrl}`; +const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`; + +const buildVersionSelectOptions = (options: DriverVersionOption[]) => { + type SelectOption = { value: string; label: string }; + type SelectGroup = { label: string; options: SelectOption[] }; + + if (options.length === 0) { + return [] as Array; + } + + const yearGroups = new Map(); + const others: SelectOption[] = []; + options.forEach((option) => { + const selectOption: SelectOption = { + value: buildVersionOptionKey(option), + label: option.displayLabel || option.version || '默认版本', + }; + const year = String(option.year || '').trim(); + if (!year) { + others.push(selectOption); + return; + } + const group = yearGroups.get(year) || []; + group.push(selectOption); + yearGroups.set(year, group); + }); + + const sortedYears = Array.from(yearGroups.keys()).sort((a, b) => { + const left = Number.parseInt(a, 10); + const right = Number.parseInt(b, 10); + const leftValid = Number.isFinite(left); + const rightValid = Number.isFinite(right); + if (leftValid && rightValid) { + return right - left; + } + return b.localeCompare(a); + }); + + const grouped: SelectGroup[] = sortedYears.map((year) => ({ + label: `${year} 年`, + options: yearGroups.get(year) || [], + })); + if (others.length > 0) { + grouped.push({ label: '其他', options: others }); + } + return grouped; +}; + const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => { const [loading, setLoading] = useState(false); const [downloadDir, setDownloadDir] = useState(''); const [rows, setRows] = useState([]); const [actionDriver, setActionDriver] = useState(''); const [progressMap, setProgressMap] = useState>({}); + const [versionMap, setVersionMap] = useState>({}); + const [selectedVersionMap, setSelectedVersionMap] = useState>({}); + const [versionLoadingMap, setVersionLoadingMap] = useState>({}); + const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState>({}); const refreshStatus = useCallback(async (toastOnError = true) => { setLoading(true); @@ -65,6 +132,8 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ type: String(item.type || '').trim(), name: String(item.name || item.type || '').trim(), builtIn: !!item.builtIn, + pinnedVersion: String(item.pinnedVersion || '').trim() || undefined, + installedVersion: String(item.installedVersion || '').trim() || undefined, packageSizeText: String(item.packageSizeText || '').trim() || undefined, runtimeAvailable: !!item.runtimeAvailable, packageInstalled: !!item.packageInstalled, @@ -82,6 +151,155 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ } }, [downloadDir]); + const loadVersionOptions = useCallback(async (row: DriverStatusRow, toastOnError = false) => { + if (row.builtIn) { + return [] as DriverVersionOption[]; + } + const driverType = String(row.type || '').trim(); + if (!driverType) { + return [] as DriverVersionOption[]; + } + setVersionLoadingMap((prev) => ({ ...prev, [driverType]: true })); + try { + const res = await GetDriverVersionList(driverType, ''); + if (!res?.success) { + if (toastOnError) { + message.error(res?.message || `${row.name} 版本列表加载失败`); + } + return [] as DriverVersionOption[]; + } + const data = (res?.data || {}) as any; + const rawVersions = Array.isArray(data.versions) ? data.versions : []; + const options: DriverVersionOption[] = rawVersions + .map((item: any) => { + const version = String(item.version || '').trim(); + const downloadUrl = String(item.downloadUrl || '').trim(); + if (!version && !downloadUrl) { + return null; + } + return { + version, + downloadUrl, + packageSizeText: String(item.packageSizeText || '').trim() || undefined, + recommended: !!item.recommended, + source: String(item.source || '').trim() || undefined, + year: String(item.year || '').trim() || undefined, + displayLabel: String(item.displayLabel || '').trim() || undefined, + } as DriverVersionOption; + }) + .filter((item: DriverVersionOption | null): item is DriverVersionOption => !!item); + + if (options.length === 0) { + const fallbackVersion = String(row.pinnedVersion || '').trim(); + const fallbackURL = String(row.defaultDownloadUrl || '').trim(); + if (fallbackVersion || fallbackURL) { + options.push({ + version: fallbackVersion, + downloadUrl: fallbackURL, + recommended: true, + source: 'fallback', + displayLabel: fallbackVersion || '默认版本', + }); + } + } + + setVersionMap((prev) => ({ ...prev, [driverType]: options })); + setSelectedVersionMap((prev) => { + const currentKey = prev[driverType]; + if (currentKey && options.some((option) => buildVersionOptionKey(option) === currentKey)) { + return prev; + } + const preferred = + options.find((option) => option.version === row.installedVersion) || + options.find((option) => option.version === row.pinnedVersion) || + options.find((option) => option.recommended) || + options[0]; + if (!preferred) { + return prev; + } + return { ...prev, [driverType]: buildVersionOptionKey(preferred) }; + }); + return options; + } catch (err: any) { + if (toastOnError) { + message.error(`加载 ${row.name} 版本列表失败:${err?.message || String(err)}`); + } + return [] as DriverVersionOption[]; + } finally { + setVersionLoadingMap((prev) => ({ ...prev, [driverType]: false })); + } + }, []); + + const loadVersionPackageSize = useCallback(async (row: DriverStatusRow, optionKey: string) => { + if (row.builtIn) { + return; + } + const driverType = String(row.type || '').trim(); + if (!driverType || !optionKey) { + return; + } + + const options = versionMap[driverType] || []; + const selectedOption = options.find((item) => buildVersionOptionKey(item) === optionKey); + if (!selectedOption) { + return; + } + if (String(selectedOption.packageSizeText || '').trim()) { + return; + } + + const versionText = String(selectedOption.version || '').trim(); + if (!versionText) { + return; + } + + const loadingKey = buildVersionSizeLoadingKey(driverType, optionKey); + if (versionSizeLoadingMap[loadingKey]) { + return; + } + + setVersionSizeLoadingMap((prev) => ({ ...prev, [loadingKey]: true })); + try { + const res = await GetDriverVersionPackageSize(driverType, versionText); + if (!res?.success) { + return; + } + const data = (res?.data || {}) as any; + const sizeText = String(data.packageSizeText || '').trim(); + if (!sizeText) { + return; + } + + setVersionMap((prev) => { + const current = prev[driverType] || []; + let changed = false; + const next = current.map((item) => { + if (buildVersionOptionKey(item) !== optionKey) { + return item; + } + if (String(item.packageSizeText || '').trim() === sizeText) { + return item; + } + changed = true; + return { ...item, packageSizeText: sizeText }; + }); + if (!changed) { + return prev; + } + return { ...prev, [driverType]: next }; + }); + } finally { + setVersionSizeLoadingMap((prev) => { + if (!prev[loadingKey]) { + return prev; + } + const next = { ...prev }; + delete next[loadingKey]; + return next; + }); + } + }, [versionMap, versionSizeLoadingMap]); + useEffect(() => { if (!open) { return; @@ -129,17 +347,30 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ }, })); try { - const result = await DownloadDriverPackage(row.type, '', downloadDir); + let options = versionMap[row.type] || []; + if (options.length === 0) { + options = await loadVersionOptions(row, true); + } + const selectedKey = selectedVersionMap[row.type]; + const selectedOption = + options.find((item) => buildVersionOptionKey(item) === selectedKey) || + options.find((item) => item.recommended) || + options[0]; + const selectedVersion = selectedOption?.version || row.pinnedVersion || ''; + const selectedDownloadURL = selectedOption?.downloadUrl || row.defaultDownloadUrl || ''; + + const result = await DownloadDriverPackage(row.type, selectedVersion, selectedDownloadURL, downloadDir); if (!result?.success) { message.error(result?.message || `安装 ${row.name} 失败`); return; } - message.success(`${row.name} 已安装启用`); + const versionTip = selectedVersion ? `(${selectedVersion})` : ''; + message.success(`${row.name}${versionTip} 已安装启用`); refreshStatus(false); } finally { setActionDriver(''); } - }, [downloadDir, refreshStatus]); + }, [downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]); const removeDriver = useCallback(async (row: DriverStatusRow) => { setActionDriver(row.type); @@ -174,7 +405,23 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ dataIndex: 'packageSizeText', key: 'packageSizeText', width: 120, - render: (_: string | undefined, row: DriverStatusRow) => row.packageSizeText || '-', + render: (_: string | undefined, row: DriverStatusRow) => { + if (row.builtIn) { + return row.packageSizeText || '-'; + } + const options = versionMap[row.type] || []; + const selectedKey = selectedVersionMap[row.type]; + const loadingKey = buildVersionSizeLoadingKey(row.type, selectedKey || ''); + const selectedOption = + options.find((item) => buildVersionOptionKey(item) === selectedKey) || + options.find((item) => item.recommended) || + options[0]; + const anyKnownSize = options.find((item) => String(item.packageSizeText || '').trim())?.packageSizeText; + if (selectedKey && versionSizeLoadingMap[loadingKey]) { + return '计算中...'; + } + return selectedOption?.packageSizeText || anyKnownSize || row.packageSizeText || '-'; + }, }, { title: '状态', @@ -224,6 +471,43 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ return ; }, }, + { + title: '驱动版本', + key: 'driverVersion', + width: 230, + render: (_: string, row: DriverStatusRow) => { + if (row.builtIn) { + return -; + } + const options = versionMap[row.type] || []; + const selectedKey = selectedVersionMap[row.type]; + const selectOptions = buildVersionSelectOptions(options); + return ( + + + + + + + + + +
+ + + + + + +
+ + )} + { password: toTrimmedString(sshRaw.password), keyPath: toTrimmedString(sshRaw.keyPath), }; + const proxyRaw = (raw.proxy && typeof raw.proxy === 'object') ? raw.proxy as Record : {}; + const proxyTypeRaw = toTrimmedString(proxyRaw.type, 'socks5').toLowerCase(); + const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5'; + const proxy = { + type: proxyType, + host: toTrimmedString(proxyRaw.host), + port: normalizePort(proxyRaw.port, proxyTypeRaw === 'http' ? 8080 : 1080), + user: toTrimmedString(proxyRaw.user), + password: toTrimmedString(proxyRaw.password), + }; const safeConfig: ConnectionConfig & Record = { ...raw, @@ -167,6 +177,8 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { database: toTrimmedString(raw.database), useSSH: !!raw.useSSH, ssh, + useProxy: !!raw.useProxy, + proxy, uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH), hosts: sanitizeAddressList(raw.hosts), topology: raw.topology === 'replica' ? 'replica' : 'single', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7fa3b54..f700677 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -6,6 +6,14 @@ export interface SSHConfig { keyPath?: string; } +export interface ProxyConfig { + type: 'socks5' | 'http'; + host: string; + port: number; + user?: string; + password?: string; +} + export interface ConnectionConfig { type: string; host: string; @@ -16,6 +24,8 @@ export interface ConnectionConfig { database?: string; useSSH?: boolean; ssh?: SSHConfig; + useProxy?: boolean; + proxy?: ProxyConfig; driver?: string; dsn?: string; timeout?: number; diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index eaf39e7..d9de709 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -48,6 +48,26 @@ export namespace connection { return a; } } + export class ProxyConfig { + type: string; + host: string; + port: number; + user?: string; + password?: string; + + static createFrom(source: any = {}) { + return new ProxyConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.type = source["type"]; + this.host = source["host"]; + this.port = source["port"]; + this.user = source["user"]; + this.password = source["password"]; + } + } export class SSHConfig { host: string; port: number; @@ -78,6 +98,8 @@ export namespace connection { database: string; useSSH: boolean; ssh: SSHConfig; + useProxy?: boolean; + proxy?: ProxyConfig; driver?: string; dsn?: string; timeout?: number; @@ -110,6 +132,8 @@ export namespace connection { this.database = source["database"]; this.useSSH = source["useSSH"]; this.ssh = this.convertValues(source["ssh"], SSHConfig); + this.useProxy = source["useProxy"]; + this.proxy = this.convertValues(source["proxy"], ProxyConfig); this.driver = source["driver"]; this.dsn = source["dsn"]; this.timeout = source["timeout"]; @@ -146,6 +170,7 @@ export namespace connection { return a; } } + export class QueryResult { success: boolean; message: string; diff --git a/go.mod b/go.mod index ea50c60..9203b37 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( go.mongodb.org/mongo-driver/v2 v2.5.0 golang.org/x/crypto v0.47.0 golang.org/x/mod v0.32.0 + golang.org/x/net v0.49.0 golang.org/x/text v0.33.0 modernc.org/sqlite v1.44.3 ) @@ -84,7 +85,6 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect - golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5 // indirect diff --git a/internal/app/app.go b/internal/app/app.go index 248093c..124b9d2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -15,6 +15,7 @@ import ( "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/db" "GoNavi-Wails/internal/logger" + proxytunnel "GoNavi-Wails/internal/proxy" ) const dbCachePingInterval = 30 * time.Second @@ -66,6 +67,7 @@ func (a *App) Shutdown(ctx context.Context) { logger.Error(err, "关闭数据库连接失败") } } + proxytunnel.CloseAllForwarders() // Close all Redis connections CloseAllRedisClients() logger.Infof("资源释放完成,应用已关闭") @@ -77,6 +79,9 @@ func getCacheKey(config connection.ConnectionConfig) string { if !config.UseSSH { config.SSH = connection.SSHConfig{} } + if !config.UseProxy { + config.Proxy = connection.ProxyConfig{} + } // 保持与驱动默认一致,避免同一连接被重复缓存 if config.Type == "postgres" && config.Database == "" { config.Database = "postgres" @@ -175,6 +180,12 @@ func formatConnSummary(config connection.ConnectionConfig) string { if config.UseSSH { b.WriteString(fmt.Sprintf(" SSH=%s:%d 用户=%s", config.SSH.Host, config.SSH.Port, config.SSH.User)) } + if config.UseProxy { + b.WriteString(fmt.Sprintf(" 代理=%s://%s:%d", strings.ToLower(strings.TrimSpace(config.Proxy.Type)), config.Proxy.Host, config.Proxy.Port)) + if strings.TrimSpace(config.Proxy.User) != "" { + b.WriteString(" 代理认证=已配置") + } + } if config.Type == "custom" { driver := strings.TrimSpace(config.Driver) @@ -269,7 +280,14 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing return nil, err } - if err := dbInst.Connect(config); err != nil { + connectConfig, proxyErr := resolveDialConfigWithProxy(config) + if proxyErr != nil { + wrapped := wrapConnectError(config, proxyErr) + logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(config), shortKey) + return nil, wrapped + } + + if err := dbInst.Connect(connectConfig); err != nil { wrapped := wrapConnectError(config, err) logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(config), shortKey) return nil, wrapped diff --git a/internal/app/db_proxy.go b/internal/app/db_proxy.go new file mode 100644 index 0000000..6adf0c2 --- /dev/null +++ b/internal/app/db_proxy.go @@ -0,0 +1,202 @@ +package app + +import ( + "fmt" + "net" + "strconv" + "strings" + + "GoNavi-Wails/internal/connection" + proxytunnel "GoNavi-Wails/internal/proxy" +) + +func resolveDialConfigWithProxy(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + config := raw + if !config.UseProxy { + config.Proxy = connection.ProxyConfig{} + return config, nil + } + + normalizedProxy, err := proxytunnel.NormalizeConfig(config.Proxy) + if err != nil { + return connection.ConnectionConfig{}, err + } + config.Proxy = normalizedProxy + + if config.UseSSH { + sshPort := config.SSH.Port + if sshPort <= 0 { + sshPort = 22 + } + forwardedSSH, err := buildProxyForwardAddress(normalizedProxy, strings.TrimSpace(config.SSH.Host), sshPort) + if err != nil { + return connection.ConnectionConfig{}, fmt.Errorf("代理连接 SSH 网关失败:%w", err) + } + config.SSH.Host = forwardedSSH.host + config.SSH.Port = forwardedSSH.port + config.UseProxy = false + config.Proxy = connection.ProxyConfig{} + return config, nil + } + + normalizedType := strings.ToLower(strings.TrimSpace(config.Type)) + if normalizedType == "sqlite" || normalizedType == "duckdb" || normalizedType == "custom" { + // 文件型/自定义 DSN 类型不走标准 host:port,不在此层改写。 + return config, nil + } + if normalizedType == "mongodb" && config.MongoSRV { + // Mongo SRV 由驱动侧 Dialer 处理代理,避免破坏 DNS SRV 拓扑发现。 + return config, nil + } + + targetPort := config.Port + if targetPort <= 0 { + targetPort = defaultPortByType(normalizedType) + } + forwardedPrimary, err := buildProxyForwardAddress(normalizedProxy, strings.TrimSpace(config.Host), targetPort) + if err != nil { + return connection.ConnectionConfig{}, err + } + config.Host = forwardedPrimary.host + config.Port = forwardedPrimary.port + + if len(config.Hosts) > 0 { + rewritten := make([]string, 0, len(config.Hosts)) + seen := make(map[string]struct{}, len(config.Hosts)) + for _, rawEntry := range config.Hosts { + targetHost, targetPort, ok := parseAddressWithDefaultPort(rawEntry, defaultPortByType(normalizedType)) + if !ok { + continue + } + forwarded, forwardErr := buildProxyForwardAddress(normalizedProxy, targetHost, targetPort) + if forwardErr != nil { + return connection.ConnectionConfig{}, forwardErr + } + rewrittenAddress := formatHostPort(forwarded.host, forwarded.port) + if _, exists := seen[rewrittenAddress]; exists { + continue + } + seen[rewrittenAddress] = struct{}{} + rewritten = append(rewritten, rewrittenAddress) + } + config.Hosts = rewritten + } + + config.UseProxy = false + config.Proxy = connection.ProxyConfig{} + return config, nil +} + +type hostPort struct { + host string + port int +} + +func buildProxyForwardAddress(proxyConfig connection.ProxyConfig, targetHost string, targetPort int) (hostPort, error) { + host := strings.TrimSpace(targetHost) + if host == "" { + host = "localhost" + } + port := targetPort + if port <= 0 { + return hostPort{}, fmt.Errorf("目标端口无效:%d", targetPort) + } + + forwarder, err := proxytunnel.GetOrCreateLocalForwarder(proxyConfig, host, port) + if err != nil { + return hostPort{}, err + } + localHost, localPort, splitOK := parseAddressWithDefaultPort(forwarder.LocalAddr, 0) + if !splitOK || localPort <= 0 { + return hostPort{}, fmt.Errorf("解析代理本地转发地址失败:%s", forwarder.LocalAddr) + } + return hostPort{host: localHost, port: localPort}, nil +} + +func parseAddressWithDefaultPort(raw string, defaultPort int) (string, int, bool) { + text := strings.TrimSpace(raw) + if text == "" { + return "", 0, false + } + + if strings.HasPrefix(text, "[") { + if host, portText, err := net.SplitHostPort(text); err == nil { + if port, convErr := strconv.Atoi(portText); convErr == nil && port > 0 && port <= 65535 { + return strings.TrimSpace(host), port, true + } + return "", 0, false + } + trimmed := strings.Trim(strings.TrimPrefix(text, "["), "]") + if trimmed != "" && defaultPort > 0 { + return trimmed, defaultPort, true + } + return "", 0, false + } + + if strings.Count(text, ":") == 0 { + if defaultPort <= 0 { + return "", 0, false + } + return text, defaultPort, true + } + + if strings.Count(text, ":") == 1 { + host, portText, err := net.SplitHostPort(text) + if err == nil { + port, convErr := strconv.Atoi(portText) + if convErr == nil && port > 0 && port <= 65535 { + return strings.TrimSpace(host), port, true + } + return "", 0, false + } + if defaultPort > 0 { + return strings.TrimSpace(text), defaultPort, true + } + return "", 0, false + } + + // IPv6 地址未带端口,使用默认端口。 + if defaultPort > 0 { + return text, defaultPort, true + } + return "", 0, false +} + +func formatHostPort(host string, port int) string { + h := strings.TrimSpace(host) + if strings.Contains(h, ":") && !strings.HasPrefix(h, "[") { + return fmt.Sprintf("[%s]:%d", h, port) + } + return fmt.Sprintf("%s:%d", h, port) +} + +func defaultPortByType(driverType string) int { + switch strings.ToLower(strings.TrimSpace(driverType)) { + case "mysql", "mariadb": + return 3306 + case "diros": + return 9030 + case "sphinx": + return 9306 + case "postgres", "vastbase": + return 5432 + case "redis": + return 6379 + case "tdengine": + return 6041 + case "oracle": + return 1521 + case "dameng": + return 5236 + case "kingbase": + return 54321 + case "sqlserver": + return 1433 + case "mongodb": + return 27017 + case "highgo": + return 5866 + default: + return 0 + } +} diff --git a/internal/connection/types.go b/internal/connection/types.go index cbc479d..cfc0253 100644 --- a/internal/connection/types.go +++ b/internal/connection/types.go @@ -9,33 +9,44 @@ type SSHConfig struct { KeyPath string `json:"keyPath"` } +// ProxyConfig holds proxy connection details +type ProxyConfig struct { + Type string `json:"type"` // socks5 | http + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user,omitempty"` + Password string `json:"password,omitempty"` +} + // ConnectionConfig holds database connection details including SSH type ConnectionConfig struct { - Type string `json:"type"` - Host string `json:"host"` - Port int `json:"port"` - User string `json:"user"` - Password string `json:"password"` - SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection - Database string `json:"database"` - UseSSH bool `json:"useSSH"` - SSH SSHConfig `json:"ssh"` - Driver string `json:"driver,omitempty"` // For custom connection - DSN string `json:"dsn,omitempty"` // For custom connection - Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30) - RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15) - URI string `json:"uri,omitempty"` // Connection URI for copy/paste - Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port - Topology string `json:"topology,omitempty"` // single | replica - MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user - MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password - ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name - AuthSource string `json:"authSource,omitempty"` // MongoDB authSource - ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference - MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme - MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism - MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user - MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password + Type string `json:"type"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` + SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection + Database string `json:"database"` + UseSSH bool `json:"useSSH"` + SSH SSHConfig `json:"ssh"` + UseProxy bool `json:"useProxy,omitempty"` + Proxy ProxyConfig `json:"proxy,omitempty"` + Driver string `json:"driver,omitempty"` // For custom connection + DSN string `json:"dsn,omitempty"` // For custom connection + Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30) + RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15) + URI string `json:"uri,omitempty"` // Connection URI for copy/paste + Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port + Topology string `json:"topology,omitempty"` // single | replica + MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user + MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password + ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name + AuthSource string `json:"authSource,omitempty"` // MongoDB authSource + ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference + MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme + MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism + MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user + MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password } // QueryResult is the standard response format for Wails methods diff --git a/internal/db/mongodb_impl.go b/internal/db/mongodb_impl.go index 35fc9f3..4b2f630 100644 --- a/internal/db/mongodb_impl.go +++ b/internal/db/mongodb_impl.go @@ -14,6 +14,7 @@ import ( "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/logger" + proxytunnel "GoNavi-Wails/internal/proxy" "GoNavi-Wails/internal/ssh" "go.mongodb.org/mongo-driver/v2/bson" @@ -29,6 +30,14 @@ type MongoDB struct { forwarder *ssh.LocalForwarder } +type mongoProxyDialer struct { + proxyConfig connection.ProxyConfig +} + +func (d *mongoProxyDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + return proxytunnel.DialContext(ctx, d.proxyConfig, network, address) +} + const defaultMongoPort = 27017 func normalizeMongoAddress(host string, port int) string { @@ -328,6 +337,9 @@ func (m *MongoDB) Connect(config connection.ConnectionConfig) error { uri := m.getURI(attemptConfig) clientOpts := options.Client().ApplyURI(uri) + if attemptConfig.UseProxy { + clientOpts.SetDialer(&mongoProxyDialer{proxyConfig: attemptConfig.Proxy}) + } client, err := mongo.Connect(clientOpts) if err != nil { errorDetails = append(errorDetails, fmt.Sprintf("%s连接失败: %v", authLabel, err)) diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go new file mode 100644 index 0000000..f378858 --- /dev/null +++ b/internal/proxy/proxy.go @@ -0,0 +1,344 @@ +package proxy + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/logger" + + xproxy "golang.org/x/net/proxy" +) + +const ( + defaultDialTimeout = 8 * time.Second +) + +type LocalForwarder struct { + LocalAddr string + RemoteAddr string + ProxyAddr string + ProxyType string + + cfg connection.ProxyConfig + listener net.Listener + closeChan chan struct{} + closeOnce sync.Once + + closed bool + closedMu sync.RWMutex +} + +var ( + forwarderMu sync.RWMutex + localForwarders = make(map[string]*LocalForwarder) +) + +func NormalizeConfig(config connection.ProxyConfig) (connection.ProxyConfig, error) { + result := connection.ProxyConfig{ + Type: strings.ToLower(strings.TrimSpace(config.Type)), + Host: strings.TrimSpace(config.Host), + Port: config.Port, + User: strings.TrimSpace(config.User), + Password: config.Password, + } + + switch result.Type { + case "socks5", "socks5h", "http": + default: + return result, fmt.Errorf("不支持的代理类型:%s", config.Type) + } + if result.Type == "socks5h" { + result.Type = "socks5" + } + if result.Host == "" { + return result, fmt.Errorf("代理主机为空") + } + if result.Port <= 0 || result.Port > 65535 { + return result, fmt.Errorf("代理端口无效:%d", result.Port) + } + return result, nil +} + +func GetOrCreateLocalForwarder(proxyConfig connection.ProxyConfig, remoteHost string, remotePort int) (*LocalForwarder, error) { + cfg, err := NormalizeConfig(proxyConfig) + if err != nil { + return nil, err + } + if strings.TrimSpace(remoteHost) == "" || remotePort <= 0 { + return nil, fmt.Errorf("无效的远端地址:%s:%d", remoteHost, remotePort) + } + + key := forwarderCacheKey(cfg, remoteHost, remotePort) + forwarderMu.RLock() + forwarder, exists := localForwarders[key] + forwarderMu.RUnlock() + if exists && forwarder != nil && !forwarder.IsClosed() { + return forwarder, nil + } + + if exists { + forwarderMu.Lock() + delete(localForwarders, key) + forwarderMu.Unlock() + } + + next, err := NewLocalForwarder(cfg, remoteHost, remotePort) + if err != nil { + return nil, err + } + + forwarderMu.Lock() + localForwarders[key] = next + forwarderMu.Unlock() + return next, nil +} + +func forwarderCacheKey(cfg connection.ProxyConfig, remoteHost string, remotePort int) string { + trimmedHost := strings.TrimSpace(remoteHost) + credential := cfg.User + "\x00" + cfg.Password + credentialHash := sha256.Sum256([]byte(credential)) + // 仅保留短指纹用于区分不同认证信息,避免在 key 日志中泄露明文口令。 + fingerprint := hex.EncodeToString(credentialHash[:8]) + return fmt.Sprintf("%s://%s:%d@%s:%d#%s", cfg.Type, cfg.Host, cfg.Port, trimmedHost, remotePort, fingerprint) +} + +func NewLocalForwarder(proxyConfig connection.ProxyConfig, remoteHost string, remotePort int) (*LocalForwarder, error) { + cfg, err := NormalizeConfig(proxyConfig) + if err != nil { + return nil, err + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("创建本地代理监听失败:%w", err) + } + + localAddr := listener.Addr().String() + remoteAddr := net.JoinHostPort(strings.TrimSpace(remoteHost), fmt.Sprintf("%d", remotePort)) + proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port)) + forwarder := &LocalForwarder{ + LocalAddr: localAddr, + RemoteAddr: remoteAddr, + ProxyAddr: proxyAddr, + ProxyType: cfg.Type, + cfg: cfg, + listener: listener, + closeChan: make(chan struct{}), + } + + go forwarder.forward() + logger.Infof("已创建代理端口转发:本地 %s -> 远端 %s(代理 %s://%s)", localAddr, remoteAddr, cfg.Type, proxyAddr) + return forwarder, nil +} + +func (f *LocalForwarder) forward() { + for { + localConn, err := f.listener.Accept() + if err != nil { + select { + case <-f.closeChan: + return + default: + logger.Warnf("接受本地代理连接失败:%v", err) + return + } + } + go f.handleConnection(localConn) + } +} + +func (f *LocalForwarder) handleConnection(localConn net.Conn) { + defer localConn.Close() + + ctx, cancel := context.WithTimeout(context.Background(), defaultDialTimeout) + remoteConn, err := dialThroughProxy(ctx, f.cfg, "tcp", f.RemoteAddr) + cancel() + if err != nil { + logger.Warnf("通过代理连接远端失败:远端=%s 代理=%s://%s 错误=%v", f.RemoteAddr, f.ProxyType, f.ProxyAddr, err) + return + } + defer remoteConn.Close() + + errc := make(chan error, 2) + var closeOnce sync.Once + closeBoth := func() { + _ = localConn.Close() + _ = remoteConn.Close() + } + go func() { + _, copyErr := io.Copy(remoteConn, localConn) + closeOnce.Do(closeBoth) + errc <- copyErr + }() + go func() { + _, copyErr := io.Copy(localConn, remoteConn) + closeOnce.Do(closeBoth) + errc <- copyErr + }() + <-errc + <-errc +} + +func (f *LocalForwarder) Close() error { + var err error + f.closeOnce.Do(func() { + f.closedMu.Lock() + f.closed = true + f.closedMu.Unlock() + close(f.closeChan) + err = f.listener.Close() + if err != nil { + logger.Warnf("关闭代理端口转发失败:%v", err) + } + }) + return err +} + +func (f *LocalForwarder) IsClosed() bool { + f.closedMu.RLock() + defer f.closedMu.RUnlock() + return f.closed +} + +func CloseAllForwarders() { + forwarderMu.Lock() + defer forwarderMu.Unlock() + + for key, forwarder := range localForwarders { + if forwarder == nil { + continue + } + _ = forwarder.Close() + logger.Infof("已关闭代理端口转发:%s", key) + } + localForwarders = make(map[string]*LocalForwarder) +} + +func DialContext(ctx context.Context, proxyConfig connection.ProxyConfig, network, address string) (net.Conn, error) { + cfg, err := NormalizeConfig(proxyConfig) + if err != nil { + return nil, err + } + return dialThroughProxy(ctx, cfg, network, address) +} + +func dialThroughProxy(ctx context.Context, cfg connection.ProxyConfig, network, address string) (net.Conn, error) { + switch cfg.Type { + case "socks5": + return dialSOCKS5(ctx, cfg, network, address) + case "http": + return dialHTTPConnect(ctx, cfg, address) + default: + return nil, fmt.Errorf("不支持的代理类型:%s", cfg.Type) + } +} + +func dialSOCKS5(ctx context.Context, cfg connection.ProxyConfig, network, address string) (net.Conn, error) { + proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port)) + var auth *xproxy.Auth + if cfg.User != "" || cfg.Password != "" { + auth = &xproxy.Auth{ + User: cfg.User, + Password: cfg.Password, + } + } + dialer, err := xproxy.SOCKS5("tcp", proxyAddr, auth, &net.Dialer{Timeout: defaultDialTimeout}) + if err != nil { + return nil, fmt.Errorf("创建 SOCKS5 代理拨号器失败:%w", err) + } + + type result struct { + conn net.Conn + err error + } + ch := make(chan result, 1) + go func() { + conn, dialErr := dialer.Dial(network, address) + ch <- result{conn: conn, err: dialErr} + }() + + select { + case <-ctx.Done(): + go func() { + r := <-ch + if r.conn != nil { + _ = r.conn.Close() + } + }() + return nil, ctx.Err() + case r := <-ch: + if r.err != nil { + return nil, fmt.Errorf("SOCKS5 代理连接失败:%w", r.err) + } + return r.conn, nil + } +} + +func dialHTTPConnect(ctx context.Context, cfg connection.ProxyConfig, address string) (net.Conn, error) { + proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port)) + dialer := &net.Dialer{Timeout: defaultDialTimeout} + conn, err := dialer.DialContext(ctx, "tcp", proxyAddr) + if err != nil { + return nil, fmt.Errorf("连接 HTTP 代理失败:%w", err) + } + + connectReq := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Opaque: address}, + Host: address, + Header: make(http.Header), + } + if cfg.User != "" || cfg.Password != "" { + raw := cfg.User + ":" + cfg.Password + connectReq.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(raw))) + } + if err := connectReq.Write(conn); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("发送 HTTP CONNECT 请求失败:%w", err) + } + + reader := bufio.NewReader(conn) + resp, err := http.ReadResponse(reader, connectReq) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("读取 HTTP CONNECT 响应失败:%w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + _ = conn.Close() + return nil, fmt.Errorf("HTTP 代理 CONNECT 失败:%s", strings.TrimSpace(resp.Status)) + } + + if reader.Buffered() == 0 { + return conn, nil + } + return &bufferedConn{Conn: conn, reader: reader}, nil +} + +type bufferedConn struct { + net.Conn + reader *bufio.Reader +} + +func (c *bufferedConn) Read(p []byte) (int, error) { + if c.reader == nil { + return c.Conn.Read(p) + } + if c.reader.Buffered() == 0 { + c.reader = nil + return c.Conn.Read(p) + } + return c.reader.Read(p) +} diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go new file mode 100644 index 0000000..4395bc1 --- /dev/null +++ b/internal/proxy/proxy_test.go @@ -0,0 +1,44 @@ +package proxy + +import ( + "strings" + "testing" + + "GoNavi-Wails/internal/connection" +) + +func TestNormalizeConfigSupportsSocks5hAlias(t *testing.T) { + cfg, err := NormalizeConfig(connection.ProxyConfig{ + Type: "SOCKS5H", + Host: "127.0.0.1", + Port: 1080, + }) + if err != nil { + t.Fatalf("NormalizeConfig returned error: %v", err) + } + if cfg.Type != "socks5" { + t.Fatalf("expected normalized proxy type socks5, got %s", cfg.Type) + } +} + +func TestForwarderCacheKeyIncludesCredentialFingerprint(t *testing.T) { + base := connection.ProxyConfig{ + Type: "socks5", + Host: "127.0.0.1", + Port: 1080, + User: "tester", + Password: "first-password", + } + other := base + other.Password = "second-password" + + keyA := forwarderCacheKey(base, "db.internal", 3306) + keyB := forwarderCacheKey(other, "db.internal", 3306) + + if keyA == keyB { + t.Fatalf("expected different cache key for different credentials") + } + if strings.Contains(keyA, base.Password) || strings.Contains(keyB, other.Password) { + t.Fatalf("cache key should not contain raw password") + } +} From 96de46cf1e3d9a39b89f613ba454413268f75da2 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Feb 2026 09:49:47 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9B=20fix(postgres-connection):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A0postgres=E5=BA=93=E6=97=B6=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E5=A4=B1=E8=B4=A5=E5=B9=B6=E6=94=AF=E6=8C=81=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E8=BF=9E=E6=8E=A5=E5=BA=93=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PostgreSQL 空 database 时按 postgres、template1、用户名同名库回退连接 - 移除后端对 database=postgres 的硬编码写死逻辑 - 连接弹窗新增 PostgreSQL 默认连接数据库(可选)配置项 - refs #120 --- frontend/src/components/ConnectionModal.tsx | 12 +++ internal/app/app.go | 4 - internal/app/methods_db.go | 6 -- internal/db/postgres_connect_test.go | 48 +++++++++++ internal/db/postgres_impl.go | 89 +++++++++++++++++---- 5 files changed, 134 insertions(+), 25 deletions(-) create mode 100644 internal/db/postgres_connect_test.go diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index cfc3b71..b6cb4e3 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -1061,6 +1061,7 @@ const ConnectionModal: React.FC<{ }); } else if (type !== 'custom') { form.setFieldsValue({ + database: '', port: defaultPort, mysqlTopology: 'single', mongoTopology: 'single', @@ -1199,6 +1200,7 @@ const ConnectionModal: React.FC<{ type: 'mysql', host: 'localhost', port: 3306, + database: '', user: 'root', useSSH: false, sshPort: 22, @@ -1338,6 +1340,16 @@ const ConnectionModal: React.FC<{ )} + {(dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase') && ( + + + + )} + {(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && ( <> diff --git a/internal/app/app.go b/internal/app/app.go index 124b9d2..9d8f081 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -82,10 +82,6 @@ func getCacheKey(config connection.ConnectionConfig) string { if !config.UseProxy { config.Proxy = connection.ProxyConfig{} } - // 保持与驱动默认一致,避免同一连接被重复缓存 - if config.Type == "postgres" && config.Database == "" { - config.Database = "postgres" - } b, _ := json.Marshal(config) sum := sha256.Sum256(b) diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index 47587f6..e31b9e8 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -190,9 +190,6 @@ func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string, return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再重命名"} } runConfig := config - if strings.TrimSpace(runConfig.Database) == "" { - runConfig.Database = "postgres" - } dbInst, err := a.getDatabase(runConfig) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} @@ -228,9 +225,6 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再删除"} } runConfig = config - if strings.TrimSpace(runConfig.Database) == "" { - runConfig.Database = "postgres" - } sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName)) default: return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除数据库", dbType)} diff --git a/internal/db/postgres_connect_test.go b/internal/db/postgres_connect_test.go new file mode 100644 index 0000000..d8707c5 --- /dev/null +++ b/internal/db/postgres_connect_test.go @@ -0,0 +1,48 @@ +package db + +import ( + "reflect" + "testing" + + "GoNavi-Wails/internal/connection" +) + +func TestResolvePostgresConnectDatabases_ExplicitDatabase(t *testing.T) { + cfg := connection.ConnectionConfig{ + Type: "postgres", + Database: "analytics", + User: "app_user", + } + + got := resolvePostgresConnectDatabases(cfg) + want := []string{"analytics"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected databases, got=%v want=%v", got, want) + } +} + +func TestResolvePostgresConnectDatabases_FallbackOrder(t *testing.T) { + cfg := connection.ConnectionConfig{ + Type: "postgres", + User: "app_user", + } + + got := resolvePostgresConnectDatabases(cfg) + want := []string{"postgres", "template1", "app_user"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected databases, got=%v want=%v", got, want) + } +} + +func TestResolvePostgresConnectDatabases_DeduplicateUserDefault(t *testing.T) { + cfg := connection.ConnectionConfig{ + Type: "postgres", + User: "postgres", + } + + got := resolvePostgresConnectDatabases(cfg) + want := []string{"postgres", "template1"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected databases, got=%v want=%v", got, want) + } +} diff --git a/internal/db/postgres_impl.go b/internal/db/postgres_impl.go index a179e91..7727773 100644 --- a/internal/db/postgres_impl.go +++ b/internal/db/postgres_impl.go @@ -24,6 +24,30 @@ type PostgresDB struct { forwarder *ssh.LocalForwarder // Store SSH tunnel forwarder } +func resolvePostgresConnectDatabases(config connection.ConnectionConfig) []string { + explicit := strings.TrimSpace(config.Database) + if explicit != "" { + return []string{explicit} + } + + candidates := []string{"postgres", "template1", strings.TrimSpace(config.User)} + seen := make(map[string]struct{}, len(candidates)) + result := make([]string, 0, len(candidates)) + for _, name := range candidates { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + continue + } + normalized := strings.ToLower(trimmed) + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + result = append(result, trimmed) + } + return result +} + func (p *PostgresDB) getDSN(config connection.ConnectionConfig) string { // postgres://user:password@host:port/dbname?sslmode=disable dbname := config.Database @@ -53,8 +77,23 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error { return fmt.Errorf("%s", reason) } - var dsn string - var err error + runConfig := config + p.pingTimeout = getConnectTimeout(config) + + cleanupOnFailure := true + defer func() { + if !cleanupOnFailure { + return + } + if p.conn != nil { + _ = p.conn.Close() + p.conn = nil + } + if p.forwarder != nil { + _ = p.forwarder.Close() + p.forwarder = nil + } + }() if config.UseSSH { // Create SSH tunnel with local port forwarding @@ -83,24 +122,44 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error { localConfig.Port = port localConfig.UseSSH = false // Disable SSH flag for DSN generation - dsn = p.getDSN(localConfig) + runConfig = localConfig logger.Infof("PostgreSQL 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port) - } else { - dsn = p.getDSN(config) } - db, err := sql.Open("postgres", dsn) - if err != nil { - return fmt.Errorf("打开数据库连接失败:%w", err) - } - p.conn = db - p.pingTimeout = getConnectTimeout(config) + attemptDBs := resolvePostgresConnectDatabases(runConfig) + var failures []string + for _, dbName := range attemptDBs { + attemptConfig := runConfig + attemptConfig.Database = dbName + dsn := p.getDSN(attemptConfig) - // Force verification - if err := p.Ping(); err != nil { - return fmt.Errorf("连接建立后验证失败:%w", err) + dbConn, err := sql.Open("postgres", dsn) + if err != nil { + failures = append(failures, fmt.Sprintf("数据库=%s 打开连接失败: %v", dbName, err)) + continue + } + p.conn = dbConn + + // Force verification + if err := p.Ping(); err != nil { + failures = append(failures, fmt.Sprintf("数据库=%s 验证失败: %v", dbName, err)) + _ = dbConn.Close() + p.conn = nil + continue + } + + if strings.TrimSpace(config.Database) == "" && !strings.EqualFold(dbName, "postgres") { + logger.Infof("PostgreSQL 自动选择连接数据库:%s", dbName) + } + + cleanupOnFailure = false + return nil } - return nil + + if len(failures) == 0 { + return fmt.Errorf("连接建立后验证失败:未找到可用的连接数据库") + } + return fmt.Errorf("连接建立后验证失败:%s", strings.Join(failures, ";")) } func (p *PostgresDB) Close() error {