diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9440e32 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,114 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Build Commands + +```bash +# Build server and client binaries +go build -o server ./cmd/server +go build -o client ./cmd/client + +# Run server (zero-config, auto-generates token and TLS cert) +./server +./server -c server.yaml # with config file + +# Run client +./client -s :7000 -t -id +./client -s :7000 -t -id -no-tls # disable TLS + +# Web UI development (in web/ directory) +cd web && npm install && npm run dev # development server +cd web && npm run build # production build (outputs to web/dist/) + +# Cross-platform build (Windows PowerShell) +.\scripts\build.ps1 + +# Cross-platform build (Linux/Mac) +./scripts/build.sh all +``` + +## Architecture Overview + +GoTunnel is an intranet penetration tool (similar to frp) with **server-centric configuration** - clients require zero configuration and receive mapping rules from the server after authentication. + +### Core Design + +- **Yamux Multiplexing**: Single TCP connection carries both control (auth, config, heartbeat) and data channels +- **Binary Protocol**: `[Type(1 byte)][Length(4 bytes)][Payload(JSON)]` - see `pkg/protocol/message.go` +- **TLS by Default**: Auto-generated self-signed ECDSA P-256 certificates, no manual setup required +- **Embedded Web UI**: Vue.js SPA embedded in server binary via `//go:embed` +- **JS Plugin System**: Extensible plugin system using goja JavaScript runtime + +### Package Structure + +``` +cmd/server/ # Server entry point +cmd/client/ # Client entry point +internal/server/ + ├── tunnel/ # Core tunnel server, client session management + ├── config/ # YAML configuration loading + ├── db/ # SQLite storage (ClientStore, JSPluginStore interfaces) + ├── app/ # Web server, SPA handler + ├── router/ # REST API endpoints (Swagger documented) + └── plugin/ # Server-side JS plugin manager +internal/client/ + └── tunnel/ # Client tunnel logic, auto-reconnect, plugin execution +pkg/ + ├── protocol/ # Message types and serialization + ├── crypto/ # TLS certificate generation + ├── relay/ # Bidirectional data relay (32KB buffers) + ├── auth/ # JWT authentication + ├── utils/ # Port availability checking + ├── version/ # Version info and update checking (Gitea API) + └── update/ # Shared update logic (download, extract tar.gz/zip) +web/ # Vue 3 + TypeScript frontend (Vite + naive-ui) +scripts/ # Build scripts (build.sh, build.ps1) +``` + +### Key Interfaces + +- `ClientStore` (internal/server/db/): Database abstraction for client rules storage +- `ServerInterface` (internal/server/router/handler/): API handler interface + +### Proxy Types + +1. **TCP** (default): Direct port forwarding (remote_port → local_ip:local_port) +2. **UDP**: UDP port forwarding +3. **HTTP**: HTTP proxy through client network +4. **HTTPS**: HTTPS proxy through client network +5. **SOCKS5**: SOCKS5 proxy through client network + +### Data Flow + +External User → Server Port → Yamux Stream → Client → Local Service + +### Configuration + +- Server: YAML config + SQLite database for client rules and JS plugins +- Client: Command-line flags only (server address, token, client ID) +- Default ports: 7000 (tunnel), 7500 (web console) + +## API Documentation + +The server provides Swagger-documented REST APIs at `/api/`. + +### Key Endpoints + +- `POST /api/auth/login` - JWT authentication +- `GET /api/clients` - List all clients +- `GET /api/client/{id}` - Get client details +- `PUT /api/client/{id}` - Update client config +- `POST /api/client/{id}/push` - Push config to online client +- `POST /api/client/{id}/plugin/{name}/{action}` - Plugin actions (start/stop/restart/delete) +- `GET /api/plugins` - List registered plugins +- `GET /api/update/check/server` - Check server updates +- `POST /api/update/apply/server` - Apply server update + +## Update System + +Both server and client support self-update from Gitea releases. + +- Release assets are compressed archives (`.tar.gz` for Linux/Mac, `.zip` for Windows) +- The `pkg/update/` package handles download, extraction, and binary replacement +- Updates can be triggered from the Web UI at `/update` page diff --git a/web/package-lock.json b/web/package-lock.json index a907def..3440108 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1217,7 +1217,6 @@ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1518,7 +1517,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2296,7 +2294,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2323,7 +2320,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2444,7 +2440,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2497,7 +2492,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2579,7 +2573,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", diff --git a/web/src/views/ClientView.vue b/web/src/views/ClientView.vue index 61eed88..be3cb3d 100644 --- a/web/src/views/ClientView.vue +++ b/web/src/views/ClientView.vue @@ -3,8 +3,7 @@ import { ref, onMounted, onUnmounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ArrowBackOutline, CreateOutline, TrashOutline, - PushOutline, AddOutline, StorefrontOutline, - ExtensionPuzzleOutline, SettingsOutline, RefreshOutline, + PushOutline, AddOutline, RefreshOutline, PlayOutline } from '@vicons/ionicons5' import GlassModal from '../components/GlassModal.vue' @@ -14,13 +13,11 @@ import { useToast } from '../composables/useToast' import { useConfirm } from '../composables/useConfirm' import { getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, restartClient, - getClientPluginConfig, updateClientPluginConfig, - getStorePlugins, installStorePlugin, getRuleSchemas, startClientPlugin, restartClientPlugin, stopClientPlugin, deleteClientPlugin, checkClientUpdate, applyClientUpdate, getClientSystemStats, getVersionInfo, getClientScreenshot, executeClientShell, type UpdateInfo, type SystemStats, type ScreenshotData } from '../api' -import type { ProxyRule, ClientPlugin, ConfigField, StorePluginInfo, RuleSchemasMap } from '../types' +import type { ProxyRule } from '../types' import InlineLogPanel from '../components/InlineLogPanel.vue' const route = useRoute() @@ -35,7 +32,6 @@ const lastPing = ref('') const remoteAddr = ref('') const nickname = ref('') const rules = ref([]) -const clientPlugins = ref([]) const loading = ref(false) const clientOs = ref('') const clientArch = ref('') @@ -65,17 +61,6 @@ const serverVersion = ref('') const shellHistory = ref([]) const historyIndex = ref(-1) -// Rule Schemas -const pluginRuleSchemas = ref({}) -const loadRuleSchemas = async () => { - try { - const { data } = await getRuleSchemas() - pluginRuleSchemas.value = data || {} - } catch (e) { - console.error('Failed to load rule schemas', e) - } -} - // Built-in Types (Added WebSocket) const builtinTypes = [ { label: 'TCP', value: 'tcp' }, @@ -96,20 +81,13 @@ const defaultRule = { local_port: 80, remote_port: 0, type: 'tcp', - enabled: true, - plugin_config: {} as Record + enabled: true } const ruleForm = ref({ ...defaultRule }) // Helper: Check if type needs local addr -const needsLocalAddr = (type: string) => { - const schema = pluginRuleSchemas.value[type] - return schema?.needs_local_addr ?? true -} - -const getExtraFields = (type: string): ConfigField[] => { - const schema = pluginRuleSchemas.value[type] - return schema?.extra_fields || [] +const needsLocalAddr = () => { + return true } // 加载服务端版本 @@ -177,7 +155,6 @@ const loadClient = async () => { remoteAddr.value = data.remote_addr || '' nickname.value = data.nickname || '' rules.value = data.rules || [] - clientPlugins.value = data.plugins || [] clientOs.value = data.os || '' clientArch.value = data.arch || '' clientVersion.value = data.version || '' @@ -368,7 +345,6 @@ const openCreateRule = () => { } const openEditRule = (rule: ProxyRule) => { - if (rule.plugin_managed) return ruleModalType.value = 'edit' ruleForm.value = JSON.parse(JSON.stringify(rule)) showRuleModal.value = true @@ -416,7 +392,7 @@ const handleRuleSubmit = async () => { message.error('请输入有效的远程端口 (1-65535)') return } - if (needsLocalAddr(ruleForm.value.type || 'tcp')) { + if (needsLocalAddr()) { if (!ruleForm.value.local_ip) { message.error('请输入本地IP') return @@ -444,126 +420,6 @@ const handleRuleSubmit = async () => { showRuleModal.value = false } -// Store & Plugin Logic -const showStoreModal = ref(false) -const storePlugins = ref([]) -const storeLoading = ref(false) -const storeInstalling = ref(null) -const showInstallConfigModal = ref(false) -const installPlugin = ref(null) -const installRemotePort = ref(8080) -const installAuthEnabled = ref(false) -const installAuthUsername = ref('') -const installAuthPassword = ref('') - -const openStoreModal = async () => { - showStoreModal.value = true - storeLoading.value = true - try { - const { data } = await getStorePlugins() - storePlugins.value = (data.plugins || []).filter((p: any) => p.download_url) - } catch (e) { - message.error('加载商店失败') - } finally { - storeLoading.value = false - } -} -const handleInstallStorePlugin = (plugin: StorePluginInfo) => { - installPlugin.value = plugin - installRemotePort.value = 8080 - showInstallConfigModal.value = true -} -const confirmInstallPlugin = async () => { - if (!installPlugin.value) return - storeInstalling.value = installPlugin.value.name - try { - await installStorePlugin( - installPlugin.value.name, - installPlugin.value.download_url || '', - installPlugin.value.signature_url || '', - clientId, - installRemotePort.value || 8080, - installPlugin.value.version, - installPlugin.value.config_schema, - installAuthEnabled.value, - installAuthUsername.value, - installAuthPassword.value - ) - message.success(`已安装 ${installPlugin.value.name}`) - showInstallConfigModal.value = false - showStoreModal.value = false - await loadClient() - } catch (e: any) { - message.error(e.response?.data || '安装失败') - } finally { - storeInstalling.value = null - } -} - -// Plugin Actions -const handleOpenPlugin = (plugin: ClientPlugin) => { - if (!plugin.remote_port) return - const hostname = window.location.hostname - const url = `http://${hostname}:${plugin.remote_port}` - window.open(url, '_blank') -} - -const toggleClientPlugin = async (plugin: ClientPlugin) => { - const newEnabled = !plugin.enabled - const updatedPlugins = clientPlugins.value.map(p => - p.id === plugin.id ? { ...p, enabled: newEnabled } : p - ) - try { - await updateClient(clientId, { - id: clientId, - nickname: nickname.value, - rules: rules.value, - plugins: updatedPlugins - }) - plugin.enabled = newEnabled - message.success(newEnabled ? `已启用 ${plugin.name}` : `已禁用 ${plugin.name}`) - } catch (e) { - message.error('操作失败') - } -} - -// Plugin Config Modal -const showConfigModal = ref(false) -const configPluginName = ref('') -const configSchema = ref([]) -const configValues = ref>({}) -const configLoading = ref(false) -const openConfigModal = async (plugin: ClientPlugin) => { - configPluginName.value = plugin.name - configLoading.value = true - showConfigModal.value = true - try { - const { data } = await getClientPluginConfig(clientId, plugin.name) - configSchema.value = data.schema || [] - configValues.value = { ...data.config } - configSchema.value.forEach(f => { - if (f.default && !configValues.value[f.key]) { - configValues.value[f.key] = f.default - } - }) - } catch (e) { - message.error('加载配置失败') - showConfigModal.value = false - } finally { - configLoading.value = false - } -} -const savePluginConfig = async () => { - try { - await updateClientPluginConfig(clientId, configPluginName.value, configValues.value) - message.success('配置已保存') - showConfigModal.value = false - loadClient() - } catch (e: any) { - message.error(e.response?.data || '保存失败') - } -} - // Standard Client Actions const confirmDelete = () => { dialog.warning({ @@ -597,7 +453,6 @@ const handleRestartClient = () => { const pollTimer = ref(null) onMounted(() => { - loadRuleSchemas() loadServerVersion() loadClient() // 启动自动轮询,每 5 秒刷新一次 @@ -611,39 +466,11 @@ onUnmounted(() => { clearInterval(pollTimer.value) pollTimer.value = null } + if (screenshotTimer.value) { + clearInterval(screenshotTimer.value) + screenshotTimer.value = null + } }) - -// Plugin Menu -const activePluginMenu = ref('') -const togglePluginMenu = (pluginId: string) => { - activePluginMenu.value = activePluginMenu.value === pluginId ? '' : pluginId -} - -// Plugin Status Actions -const handleStartPlugin = async (plugin: ClientPlugin) => { - const rule = rules.value.find(r => r.type === plugin.name) - const ruleName = rule?.name || plugin.name - try { await startClientPlugin(clientId, plugin.id, ruleName); message.success('已启动'); plugin.running = true } catch(e:any){ message.error(e.message) } -} -const handleRestartPlugin = async (plugin: ClientPlugin) => { - const rule = rules.value.find(r => r.type === plugin.name) - const ruleName = rule?.name || plugin.name - try { await restartClientPlugin(clientId, plugin.id, ruleName); message.success('已重启'); plugin.running = true } catch(e:any){ message.error(e.message)} -} -const handleStopPlugin = async (plugin: ClientPlugin) => { - const rule = rules.value.find(r => r.type === plugin.name) - const ruleName = rule?.name || plugin.name - try { await stopClientPlugin(clientId, plugin.id, ruleName); message.success('已停止'); plugin.running = false } catch(e:any){ message.error(e.message)} -} -const handleDeletePlugin = (plugin: ClientPlugin) => { - dialog.warning({ - title: '确认删除', content: `确定要删除插件 ${plugin.name} 吗?`, - positiveText: '删除', negativeText: '取消', - onPositiveClick: async () => { - await deleteClientPlugin(clientId, plugin.id); message.success('已删除'); loadClient() - } - }) -}