34 Commits
v1.0.6 ... main

Author SHA1 Message Date
b161b7ac79 feat: Add support for Windows ARM64 architecture in build workflows and scripts 2026-03-22 22:46:27 +08:00
0a932211f1 fix: Update references from Gitea to GitHub for versioning and self-update
Some checks failed
Build Multi-Platform Binaries / build-android-apk (push) Failing after 6s
Build Multi-Platform Binaries / build-frontend (push) Successful in 33s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m37s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m46s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m28s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 2m0s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m21s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m52s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 2m2s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m18s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m21s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m58s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m33s
2026-03-22 22:37:27 +08:00
cdc1dd60d1 fix: Correct binding reference for status metadata in MainActivity 2026-03-22 22:10:01 +08:00
21621b15f4 feat: Add SettingsActivity for configuration management and logging
- Introduced SettingsActivity to manage server address and token settings.
- Integrated LogStore for logging status updates and messages.
- Updated MainActivity to navigate to SettingsActivity and handle configuration.
- Modified UI in activity_main.xml and activity_settings.xml for improved user experience.
- Adjusted color scheme in colors.xml for better visibility and aesthetics.
- Enhanced string resources in strings.xml for clarity and consistency.
- Refactored notification handling in NotificationHelper.kt to use status labels.
- Updated TunnelService to log status changes and messages.
2026-03-22 22:04:14 +08:00
4b09fe817d Update theme colors to use android namespace for background attributes 2026-03-22 21:35:57 +08:00
f644d3764a Add Android build workflow and setup for APK generation
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 39s
Build Multi-Platform Binaries / build-android-apk (push) Failing after 3m40s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m27s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 4m38s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m21s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
2026-03-22 21:30:12 +08:00
4210ab7675 Add Android client support and unify cross-platform builds 2026-03-22 21:25:09 +08:00
6558d1acdb Refactor install command generation and update response structure
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
2026-03-19 20:49:23 +08:00
Flik
8901581d0c Merge pull request #2 from Flikify/codex/build-github-workflow-for-new-changes
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
CI overhaul and major frontend UI refactor (new components, layouts, and release workflow)
2026-03-19 20:22:23 +08:00
Flik
c6bab83e24 Refactor web console UI 2026-03-19 20:21:05 +08:00
Flik
9cd74b43d0 Merge pull request #1 from Flikify/codex/build-github-workflow-for-new-changes
Add GitHub Actions CI and Release workflows
2026-03-19 20:12:13 +08:00
Flik
2d8cc8ebe5 Add GitHub CI and release workflows 2026-03-19 20:08:00 +08:00
58bb324d82 Merge remote-tracking branch 'refs/remotes/github-temp/main' 2026-03-19 19:47:35 +08:00
bed78a36d0 Remove plugin store config from server settings
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 28s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m24s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m33s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m17s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m54s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m8s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m46s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m57s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m9s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m50s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m24s
2026-03-19 19:42:03 +08:00
e4999abf47 Remove manual client ID and TLS CLI options
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 34s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m20s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m33s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m16s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m48s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m7s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m46s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m31s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m58s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m35s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
2026-03-19 19:32:57 +08:00
937536e422 feat: add AGENTS.md for build commands and architecture overview
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 27s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m16s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m34s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m8s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m51s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m6s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m44s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m4s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m3s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m51s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m23s
2026-03-17 23:54:44 +08:00
838bde28f0 refactor: remove unused code for HTTP method handling in update management
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Failing after 14s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been skipped
2026-03-17 23:17:57 +08:00
743b91f3bf Merge branch 'main' of https://git.92coco.cn/flik/GoTunnel
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Failing after 16s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been skipped
2026-03-17 23:16:32 +08:00
5a03d9e1f1 feat: remove unused plugin version comparison and types, refactor proxy server to support authentication
- Deleted version comparison logic from `pkg/plugin/sign/version.go`.
- Removed unused types and constants from `pkg/plugin/types.go`.
- Updated `pkg/protocol/message.go` to remove plugin-related message types.
- Enhanced `pkg/proxy/http.go` and `pkg/proxy/socks5.go` to include username/password authentication for HTTP and SOCKS5 proxies.
- Modified `pkg/proxy/server.go` to pass authentication parameters to server constructors.
- Added new API endpoint to generate installation commands with a token for clients.
- Created database functions to manage installation tokens in `internal/server/db/install_token.go`.
- Implemented the installation command generation logic in `internal/server/router/handler/install.go`.
- Updated web frontend to support installation command generation and display in `web/src/views/ClientsView.vue`.
2026-03-17 23:16:30 +08:00
dcfd2f4466 chore: remove test configuration file 2026-03-17 23:16:08 +08:00
f13dc2451c 删除 frps 2026-03-10 20:50:40 +08:00
1c71a7633b 上传文件至「/」 2026-03-10 17:03:42 +08:00
b7a1a249a4 fix(web): resolve TypeScript errors in ClientView
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 27s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m29s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m37s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m29s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 2m1s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m21s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m49s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 2m1s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m14s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
- Remove unused imports (ImageOutline, TerminalOutline, ShellResult)
- Add null coalescing for array access in shell history

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:42:05 +08:00
5cee8daabc feat: add remote screenshot and shell execution capabilities
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Failing after 16s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been skipped
- Add screenshot capture API with quality control
- Add remote shell command execution with timeout
- Implement client-side handlers for screenshot and shell requests
- Add Web UI components for screenshot viewing and shell terminal
- Support auto-refresh for screenshot monitoring
- Add shell command history navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:33:45 +08:00
Flik
a9ca714b24 Merge branch 'main' of https://git.92coco.cn/flik/GoTunnel
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 33s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m35s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m47s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m27s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 2m11s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m47s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m16s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m51s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m59s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m30s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m58s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m24s
2026-03-06 21:23:58 +08:00
Flik
d6627a292d feat(client, server): enhance self-update process with fallback directory handling and UAC elevation for Windows 2026-03-06 21:22:58 +08:00
e0d88e9ad7 feat: Implement core tunnel server with client management, authentication, and SOCKS5/HTTP proxy capabilities.
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
2026-02-03 21:59:55 +08:00
Flik
ba9edd3c02 feat(client): enhance data directory handling and update permissions check for self-update
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m32s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m35s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m30s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m52s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m17s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m39s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m43s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m58s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m16s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m48s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m22s
2026-01-29 16:29:40 +08:00
Flik
e40d079f7a feat(server): add traffic storage and statistics tracking for improved traffic management
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
2026-01-29 15:38:27 +08:00
Flik
8ce5b149f7 fix(server): update client nickname handling to prevent overwriting manual names
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 29s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m35s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m42s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m25s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m50s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m15s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m44s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m4s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m16s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m46s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m19s
2026-01-29 15:08:04 +08:00
Flik
0a41e10793 feat(client, server): add client name handling and machine ID retrieval
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
2026-01-27 08:56:05 +08:00
Flik
3386b0fcb6 fix(client): 移除日志前缀并改进代理配置处理
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
- 移除了所有客户端日志消息中的 [Client] 前缀
- 修改 handleProxyConfig 函数以接收 stream 参数并添加延迟关闭
- 更新 sendProxyConfig 函数以等待客户端配置确认
- 改进了代理配置同步的可靠性
2026-01-23 18:10:51 +08:00
Flik
98a5525e6d feat(client): 添加服务端版本检查和客户端更新逻辑
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 27s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m24s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m33s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m18s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m16s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Failing after 1m39s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m49s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m26s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m53s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m16s
- 引入 getVersionInfo API 和服务端版本获取功能
- 实现版本比较算法用于判断更新需求
- 添加服务端版本加载和目标版本计算逻辑
- 更新客户端版本显示为可更新目标版本
- 优化样式表移除不透明背景设置
- 调整进度条外观样式增强视觉效果
2026-01-23 08:40:54 +08:00
Flik
6496d56e0e 1 2026-01-22 14:11:56 +08:00
103 changed files with 6212 additions and 9365 deletions

View File

@@ -64,6 +64,20 @@ jobs:
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '8.7'
- name: Build frontend
run: |
cd web
@@ -75,6 +89,17 @@ jobs:
echo "Frontend build completed"
ls -la internal/server/app/dist/
- name: Build Android release APK
run: |
cd android
gradle --no-daemon assembleRelease
cd ..
mkdir -p dist
cp android/app/build/outputs/apk/release/app-release-unsigned.apk \
"dist/gotunnel-android-${{ inputs.version }}-release-unsigned.apk"
echo "Android APK build completed"
ls -lah dist/gotunnel-android-${{ inputs.version }}-release-unsigned.apk
- name: Build all platforms
run: |
mkdir -p dist
@@ -182,6 +207,7 @@ jobs:
echo "- **Linux (amd64/arm64)**: \`.tar.gz\` files" >> release_notes.md
echo "- **macOS (amd64/arm64)**: \`.tar.gz\` files" >> release_notes.md
echo "- **Windows (amd64)**: \`.zip\` files" >> release_notes.md
echo "- **Android**: unsigned \`.apk\` file" >> release_notes.md
echo "" >> release_notes.md
echo "Verify downloads with \`SHA256SUMS\`" >> release_notes.md
fi
@@ -197,6 +223,7 @@ jobs:
files: |-
dist/*.tar.gz
dist/*.zip
dist/*.apk
dist/SHA256SUMS
draft: ${{ inputs.draft }}
prerelease: ${{ inputs.prerelease }}

View File

@@ -40,6 +40,37 @@ jobs:
path: web/dist
retention-days: 1
build-android-apk:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '8.7'
- name: Build Android debug APK
working-directory: android
run: gradle --no-daemon assembleDebug
- name: Upload Android APK
uses: actions/upload-artifact@v3
with:
name: gotunnel-android-debug-apk
path: android/app/build/outputs/apk/debug/app-debug.apk
retention-days: 7
build-binaries:
needs: build-frontend
runs-on: golang

152
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,152 @@
name: CI Build
on:
push:
pull_request:
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
frontend:
name: Build frontend
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install frontend dependencies
working-directory: web
run: npm ci
- name: Build frontend
working-directory: web
run: npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: web/dist
retention-days: 1
android-apk:
name: Build Android debug APK
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '8.7'
- name: Build debug APK
working-directory: android
run: gradle --no-daemon assembleDebug
- name: Upload Android APK artifact
uses: actions/upload-artifact@v4
with:
name: gotunnel-android-debug-apk
path: android/app/build/outputs/apk/debug/app-debug.apk
retention-days: 7
build:
name: Build ${{ matrix.goos }}/${{ matrix.goarch }}
needs: frontend
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
goos: linux
goarch: amd64
- runner: ubuntu-latest
goos: linux
goarch: arm64
- runner: windows-latest
goos: windows
goarch: amd64
- runner: windows-latest
goos: windows
goarch: arm64
- runner: macos-latest
goos: darwin
goarch: amd64
- runner: macos-latest
goos: darwin
goarch: arm64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Download frontend artifact
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: internal/server/app/dist
- name: Download Go modules
run: go mod download
- name: Build server and client
shell: bash
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
mkdir -p build/${GOOS}_${GOARCH}
EXT=""
if [ "$GOOS" = "windows" ]; then
EXT=".exe"
fi
BUILD_TIME="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
GIT_COMMIT="${GITHUB_SHA::7}"
VERSION="${GITHUB_REF_NAME}"
if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
VERSION="${GITHUB_SHA::7}"
fi
LDFLAGS="-s -w -X 'main.Version=${VERSION}' -X 'main.BuildTime=${BUILD_TIME}' -X 'main.GitCommit=${GIT_COMMIT}'"
go build -trimpath -ldflags "$LDFLAGS" -o "build/${GOOS}_${GOARCH}/server${EXT}" ./cmd/server
go build -trimpath -ldflags "$LDFLAGS" -o "build/${GOOS}_${GOARCH}/client${EXT}" ./cmd/client
- name: Upload binaries artifact
uses: actions/upload-artifact@v4
with:
name: gotunnel-${{ matrix.goos }}-${{ matrix.goarch }}
path: build/${{ matrix.goos }}_${{ matrix.goarch }}/
retention-days: 7

278
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,278 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish, e.g. v1.2.3'
required: true
type: string
permissions:
contents: write
concurrency:
group: release-${{ github.event.inputs.tag || github.ref }}
cancel-in-progress: false
jobs:
frontend:
name: Build frontend
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install frontend dependencies
working-directory: web
run: npm ci
- name: Build frontend
working-directory: web
run: npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: web/dist
retention-days: 1
android-apk:
name: Package Android release APK
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve release metadata
id: meta
shell: bash
run: |
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
TAG="${{ github.event.inputs.tag }}"
else
TAG="${GITHUB_REF_NAME}"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '8.7'
- name: Build release APK
working-directory: android
run: gradle --no-daemon assembleRelease
- name: Package Android release asset
id: package
shell: bash
run: |
mkdir -p dist/out
ARCHIVE="gotunnel-android-${{ steps.meta.outputs.tag }}-release-unsigned.apk"
cp android/app/build/outputs/apk/release/app-release-unsigned.apk "dist/out/${ARCHIVE}"
echo "archive=dist/out/${ARCHIVE}" >> "$GITHUB_OUTPUT"
- name: Upload Android release artifact
uses: actions/upload-artifact@v4
with:
name: release-android-apk
path: ${{ steps.package.outputs.archive }}
retention-days: 1
build-assets:
name: Package ${{ matrix.component }} ${{ matrix.goos }}/${{ matrix.goarch }}
needs: frontend
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- component: server
goos: linux
goarch: amd64
archive_ext: tar.gz
- component: client
goos: linux
goarch: amd64
archive_ext: tar.gz
- component: server
goos: linux
goarch: arm64
archive_ext: tar.gz
- component: client
goos: linux
goarch: arm64
archive_ext: tar.gz
- component: server
goos: darwin
goarch: amd64
archive_ext: tar.gz
- component: client
goos: darwin
goarch: amd64
archive_ext: tar.gz
- component: server
goos: darwin
goarch: arm64
archive_ext: tar.gz
- component: client
goos: darwin
goarch: arm64
archive_ext: tar.gz
- component: server
goos: windows
goarch: amd64
archive_ext: zip
- component: client
goos: windows
goarch: amd64
archive_ext: zip
- component: server
goos: windows
goarch: arm64
archive_ext: zip
- component: client
goos: windows
goarch: arm64
archive_ext: zip
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Download frontend artifact
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: internal/server/app/dist
- name: Download Go modules
run: go mod download
- name: Resolve release metadata
id: meta
shell: bash
run: |
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
TAG="${{ github.event.inputs.tag }}"
else
TAG="${GITHUB_REF_NAME}"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "commit=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
- name: Build binary
shell: bash
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
VERSION: ${{ steps.meta.outputs.tag }}
GIT_COMMIT: ${{ steps.meta.outputs.commit }}
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
run: |
mkdir -p dist/package
EXT=""
if [ "$GOOS" = "windows" ]; then
EXT=".exe"
fi
OUTPUT_NAME="${{ matrix.component }}${EXT}"
LDFLAGS="-s -w -X 'main.Version=${VERSION}' -X 'main.BuildTime=${BUILD_TIME}' -X 'main.GitCommit=${GIT_COMMIT}'"
go build -trimpath -ldflags "$LDFLAGS" -o "dist/package/${OUTPUT_NAME}" ./cmd/${{ matrix.component }}
- name: Create release archive
id: package
shell: bash
run: |
TAG="${{ steps.meta.outputs.tag }}"
ARCHIVE="gotunnel-${{ matrix.component }}-${TAG}-${{ matrix.goos }}-${{ matrix.goarch }}.${{ matrix.archive_ext }}"
mkdir -p dist/out
if [ "${{ matrix.archive_ext }}" = "zip" ]; then
(cd dist/package && zip -r "../out/${ARCHIVE}" .)
else
tar -C dist/package -czf "dist/out/${ARCHIVE}" .
fi
echo "archive=dist/out/${ARCHIVE}" >> "$GITHUB_OUTPUT"
- name: Upload release asset artifact
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.component }}-${{ matrix.goos }}-${{ matrix.goarch }}
path: ${{ steps.package.outputs.archive }}
retention-days: 1
publish:
name: Publish release
needs:
- build-assets
- android-apk
runs-on: ubuntu-latest
steps:
- name: Download packaged assets
uses: actions/download-artifact@v4
with:
path: release-artifacts
pattern: release-*
merge-multiple: true
- name: Resolve release metadata
id: meta
shell: bash
run: |
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
TAG="${{ github.event.inputs.tag }}"
else
TAG="${GITHUB_REF_NAME}"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Generate checksums
shell: bash
run: |
cd release-artifacts
sha256sum * > SHA256SUMS.txt
- name: Create or update GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.meta.outputs.tag }}
target_commitish: ${{ github.sha }}
generate_release_notes: true
fail_on_unmatched_files: true
files: |
release-artifacts/*

113
AGENTS.md Normal file
View File

@@ -0,0 +1,113 @@
# 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 <server>:7000 -t <token>
# 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 (GitHub Releases 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)
- 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 GitHub 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

View File

@@ -14,8 +14,7 @@ go build -o client ./cmd/client
./server -c server.yaml # with config file
# Run client
./client -s <server>:7000 -t <token> -id <client-id>
./client -s <server>:7000 -t <token> -id <client-id> -no-tls # disable TLS
./client -s <server>:7000 -t <token>
# Web UI development (in web/ directory)
cd web && npm install && npm run dev # development server
@@ -60,14 +59,8 @@ pkg/
├── 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)
└── plugin/ # Plugin system core
├── types.go # Plugin interfaces
├── registry.go # Plugin registry
├── script/ # JS plugin runtime (goja)
├── sign/ # Plugin signature verification
└── store/ # Plugin persistence (SQLite)
├── version/ # Version info and update checking (GitHub Releases 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)
```
@@ -75,23 +68,16 @@ scripts/ # Build scripts (build.sh, build.ps1)
### Key Interfaces
- `ClientStore` (internal/server/db/): Database abstraction for client rules storage
- `JSPluginStore` (internal/server/db/): JS plugin persistence
- `ServerInterface` (internal/server/router/handler/): API handler interface
- `ClientPlugin` (pkg/plugin/): Plugin interface for client-side plugins
### Proxy Types
**内置类型** (直接在 tunnel 中处理):
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
**JS 插件类型** (通过 goja 运行时):
- Custom application plugins (file-server, api-server, etc.)
- Runs on client side with sandbox restrictions
### Data Flow
External User → Server Port → Yamux Stream → Client → Local Service
@@ -99,47 +85,9 @@ 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)
- Client: Command-line flags only (server address, token)
- Default ports: 7000 (tunnel), 7500 (web console)
## Plugin System
GoTunnel supports a JavaScript-based plugin system using the goja runtime.
### Plugin Architecture
- **内置协议**: tcp, udp, http, https, socks5 直接在 tunnel 代码中处理
- **JS Plugins**: 自定义应用插件通过 goja 运行时在客户端执行
- **Plugin Store**: 从官方商店浏览和安装插件
- **Signature Verification**: 插件需要签名验证才能运行
### JS Plugin Lifecycle
```javascript
function metadata() {
return {
name: "plugin-name",
version: "1.0.0",
type: "app",
description: "Plugin description",
author: "Author"
};
}
function start() { /* called on plugin start */ }
function handleConn(conn) { /* handle each connection */ }
function stop() { /* called on plugin stop */ }
```
### Plugin APIs
- **Basic**: `log()`, `config()`
- **Connection**: `conn.Read()`, `conn.Write()`, `conn.Close()`
- **File System**: `fs.readFile()`, `fs.writeFile()`, `fs.readDir()`, `fs.stat()`, etc.
- **HTTP**: `http.serve()`, `http.json()`, `http.sendFile()`
See `PLUGINS.md` for detailed plugin development documentation.
## API Documentation
The server provides Swagger-documented REST APIs at `/api/`.
@@ -158,7 +106,7 @@ The server provides Swagger-documented REST APIs at `/api/`.
## Update System
Both server and client support self-update from Gitea releases.
Both server and client support self-update from GitHub releases.
- Release assets are compressed archives (`.tar.gz` for Linux/Mac, `.zip` for Windows)
- The `pkg/update/` package handles download, extraction, and binary replacement

View File

@@ -1,16 +1,13 @@
# GoTunnel Makefile
.PHONY: all build-frontend sync-frontend build-server build-client clean help
.PHONY: all build-frontend sync-frontend sync-only build-server build-client build-all-platforms build-current-platform build-android clean help
# 默认目标
all: build-frontend sync-frontend build-server build-client
all: build-frontend sync-frontend build-current-platform
# 构建前端
build-frontend:
@echo "Building frontend..."
cd web && npm ci && npm run build
# 同步前端到 embed 目录
sync-frontend:
@echo "Syncing frontend to embed directory..."
ifeq ($(OS),Windows_NT)
@@ -21,7 +18,6 @@ else
cp -r web/dist internal/server/app/dist
endif
# 仅同步(不重新构建前端)
sync-only:
@echo "Syncing existing frontend build..."
ifeq ($(OS),Windows_NT)
@@ -32,33 +28,38 @@ else
cp -r web/dist internal/server/app/dist
endif
# 构建服务端(当前平台)
build-server:
@echo "Building server..."
go build -ldflags="-s -w" -o gotunnel-server ./cmd/server
@echo "Building server for current platform..."
go build -buildvcs=false -trimpath -ldflags="-s -w" -o gotunnel-server ./cmd/server
# 构建客户端(当前平台)
build-client:
@echo "Building client..."
go build -ldflags="-s -w" -o gotunnel-client ./cmd/client
@echo "Building client for current platform..."
go build -buildvcs=false -trimpath -ldflags="-s -w" -o gotunnel-client ./cmd/client
# 构建 Linux ARM64 服务端
build-server-linux-arm64: sync-only
@echo "Building server for Linux ARM64..."
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w" -o gotunnel-server-linux-arm64 ./cmd/server
build-current-platform:
@echo "Building current platform binaries..."
ifeq ($(OS),Windows_NT)
powershell -ExecutionPolicy Bypass -File scripts/build.ps1 current
else
./scripts/build.sh current
endif
# 构建 Linux AMD64 服务端
build-server-linux-amd64: sync-only
@echo "Building server for Linux AMD64..."
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o gotunnel-server-linux-amd64 ./cmd/server
build-all-platforms:
@echo "Building all desktop platform binaries..."
ifeq ($(OS),Windows_NT)
powershell -ExecutionPolicy Bypass -File scripts/build.ps1 all -NoUPX
else
./scripts/build.sh all
endif
# 完整构建(包含前端)
full-build: build-frontend sync-frontend build-server build-client
build-android:
@echo "Android build placeholder..."
ifeq ($(OS),Windows_NT)
powershell -ExecutionPolicy Bypass -File scripts/build.ps1 android
else
./scripts/build.sh android
endif
# 开发模式:快速构建(假设前端已构建)
dev-build: sync-only build-server
# 清理构建产物
clean:
@echo "Cleaning..."
ifeq ($(OS),Windows_NT)
@@ -68,21 +69,21 @@ ifeq ($(OS),Windows_NT)
if exist gotunnel-client.exe del gotunnel-client.exe
if exist gotunnel-server-* del gotunnel-server-*
if exist gotunnel-client-* del gotunnel-client-*
if exist build rmdir /s /q build
else
rm -f gotunnel-server gotunnel-client gotunnel-server-* gotunnel-client-*
rm -rf build
endif
# 帮助
help:
@echo "Available targets:"
@echo " all - Build frontend, sync, and build binaries"
@echo " all - Build frontend, sync, and current platform binaries"
@echo " build-frontend - Build frontend (npm)"
@echo " sync-frontend - Sync web/dist to internal/server/app/dist"
@echo " sync-only - Sync without rebuilding frontend"
@echo " build-server - Build server for current platform"
@echo " build-client - Build client for current platform"
@echo " build-server-linux-arm64 - Cross-compile server for Linux ARM64"
@echo " build-server-linux-amd64 - Cross-compile server for Linux AMD64"
@echo " full-build - Complete build with frontend"
@echo " dev-build - Quick build (assumes frontend exists)"
@echo " build-current-platform - Build server/client into build/<os>_<arch>/"
@echo " build-all-platforms - Build Windows/Linux/macOS server/client binaries"
@echo " build-android - Android build placeholder"
@echo " clean - Remove build artifacts"

View File

@@ -1,645 +0,0 @@
# GoTunnel 插件开发指南
本文档介绍如何为 GoTunnel 开发 JS 插件。JS 插件基于 [goja](https://github.com/dop251/goja) 运行时,运行在客户端上。
## 目录
- [快速开始](#快速开始)
- [插件结构](#插件结构)
- [API 参考](#api-参考)
- [示例插件](#示例插件)
- [插件签名](#插件签名)
- [发布到商店](#发布到商店)
---
## 快速开始
### 最小插件示例
```javascript
// 必须:定义插件元数据
function metadata() {
return {
name: "my-plugin",
version: "1.0.0",
type: "app",
description: "My first plugin",
author: "Your Name"
};
}
// 可选:插件启动时调用
function start() {
log("Plugin started");
}
// 必须:处理连接
function handleConn(conn) {
// 处理连接逻辑
conn.Close();
}
// 可选:插件停止时调用
function stop() {
log("Plugin stopped");
}
```
---
## 插件结构
### 生命周期函数
| 函数 | 必须 | 说明 |
|------|------|------|
| `metadata()` | 否 | 返回插件元数据,不定义则使用默认值 |
| `start()` | 否 | 插件启动时调用 |
| `handleConn(conn)` | 是 | 处理每个连接 |
| `stop()` | 否 | 插件停止时调用 |
### 元数据字段
```javascript
function metadata() {
return {
name: "plugin-name", // 插件名称
version: "1.0.0", // 版本号
type: "app", // 类型: "app" (应用插件)
description: "描述", // 插件描述
author: "作者" // 作者名称
};
}
```
---
## API 参考
### 基础 API
#### `log(message)`
输出日志信息。
```javascript
log("Hello, World!");
// 输出: [JS:plugin-name] Hello, World!
```
#### `config(key)`
获取插件配置值。
```javascript
var port = config("port");
var host = config("host") || "127.0.0.1";
```
---
### 连接 API (conn)
`handleConn` 函数接收的 `conn` 对象提供以下方法:
#### `conn.Read(size)`
读取数据,返回字节数组,失败返回 `null`
```javascript
var data = conn.Read(1024);
if (data) {
log("Received " + data.length + " bytes");
}
```
#### `conn.Write(data)`
写入数据,返回写入的字节数。
```javascript
var written = conn.Write(data);
log("Wrote " + written + " bytes");
```
#### `conn.Close()`
关闭连接。
```javascript
conn.Close();
```
---
### 文件系统 API (fs)
所有文件操作都在沙箱中执行,有路径和大小限制。
#### `fs.readFile(path)`
读取文件内容。
```javascript
var result = fs.readFile("/path/to/file.txt");
if (result.error) {
log("Error: " + result.error);
} else {
log("Content: " + result.data);
}
```
#### `fs.writeFile(path, content)`
写入文件内容。
```javascript
var result = fs.writeFile("/path/to/file.txt", "Hello");
if (result.ok) {
log("File written");
} else {
log("Error: " + result.error);
}
```
#### `fs.readDir(path)`
读取目录内容。
```javascript
var result = fs.readDir("/path/to/dir");
if (!result.error) {
for (var i = 0; i < result.entries.length; i++) {
var entry = result.entries[i];
log(entry.name + " - " + (entry.isDir ? "DIR" : entry.size + " bytes"));
}
}
```
#### `fs.stat(path)`
获取文件信息。
```javascript
var result = fs.stat("/path/to/file");
if (!result.error) {
log("Name: " + result.name);
log("Size: " + result.size);
log("IsDir: " + result.isDir);
log("ModTime: " + result.modTime);
}
```
#### `fs.exists(path)`
检查文件是否存在。
```javascript
var result = fs.exists("/path/to/file");
if (result.exists) {
log("File exists");
}
```
#### `fs.mkdir(path)`
创建目录。
```javascript
var result = fs.mkdir("/path/to/new/dir");
if (result.ok) {
log("Directory created");
}
```
#### `fs.remove(path)`
删除文件或目录。
```javascript
var result = fs.remove("/path/to/file");
if (result.ok) {
log("Removed");
}
```
---
### HTTP API (http)
用于构建简单的 HTTP 服务。
#### `http.serve(conn, handler)`
处理 HTTP 请求。
```javascript
function handleConn(conn) {
http.serve(conn, function(req) {
return {
status: 200,
contentType: "application/json",
body: http.json({ message: "Hello", path: req.path })
};
});
}
```
**请求对象 (req):**
| 字段 | 类型 | 说明 |
|------|------|------|
| `method` | string | HTTP 方法 (GET, POST, etc.) |
| `path` | string | 请求路径 |
| `body` | string | 请求体 |
**响应对象:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `status` | number | HTTP 状态码 (默认 200) |
| `contentType` | string | Content-Type (默认 application/json) |
| `body` | string | 响应体 |
#### `http.json(data)`
将对象序列化为 JSON 字符串。
```javascript
var jsonStr = http.json({ name: "test", value: 123 });
// 返回: '{"name":"test","value":123}'
```
#### `http.sendFile(conn, filePath)`
发送文件作为 HTTP 响应。
```javascript
function handleConn(conn) {
http.sendFile(conn, "/path/to/index.html");
}
```
---
### 增强 API (Enhanced APIs)
GoTunnel v2.0+ 提供了更多强大的 API 能力。
#### `logger` (日志)
推荐使用结构化日志替代简单的 `log()`
- `logger.info(msg)`
- `logger.warn(msg)`
- `logger.error(msg)`
```javascript
logger.info("Server started");
logger.error("Connection failed");
```
#### `config` (配置)
增强的配置获取方式。
- `config.get(key)`: 获取配置值
- `config.getAll()`: 获取所有配置
```javascript
var all = config.getAll();
var port = config.get("port");
```
#### `storage` (持久化存储)
简单的 Key-Value 存储,数据保存在客户端本地。
- `storage.get(key, default)`
- `storage.set(key, value)`
- `storage.delete(key)`
- `storage.keys()`
```javascript
storage.set("last_run", Date.now());
var last = storage.get("last_run", 0);
```
#### `event` (事件总线)
插件内部或插件间的事件通信。
- `event.on(name, callback)`
- `event.emit(name, data)`
- `event.off(name)`
```javascript
event.on("user_login", function(user) {
logger.info("User logged in: " + user);
});
event.emit("user_login", "admin");
```
#### `request` (HTTP 请求)
发起外部 HTTP 请求。
- `request.get(url)`
- `request.post(url, contentType, body)`
```javascript
var res = request.get("https://api.ipify.org");
logger.info("My IP: " + res.body);
```
#### `notify` (通知)
发送系统通知。
- `notify.send(title, message)`
```javascript
notify.send("Download Complete", "File saved to disk");
```
---
## 示例插件
### Echo 服务
```javascript
function metadata() {
return {
name: "echo",
version: "1.0.0",
type: "app",
description: "Echo back received data"
};
}
function handleConn(conn) {
while (true) {
var data = conn.Read(4096);
if (!data || data.length === 0) {
break;
}
conn.Write(data);
}
conn.Close();
}
```
### HTTP 文件服务器
```javascript
function metadata() {
return {
name: "file-server",
version: "1.0.0",
type: "app",
description: "Simple HTTP file server"
};
}
var rootDir = "";
function start() {
rootDir = config("root") || "/tmp";
log("Serving files from: " + rootDir);
}
function handleConn(conn) {
http.serve(conn, function(req) {
if (req.method === "GET") {
var filePath = rootDir + req.path;
if (req.path === "/") {
filePath = rootDir + "/index.html";
}
var stat = fs.stat(filePath);
if (stat.error) {
return { status: 404, body: "Not Found" };
}
if (stat.isDir) {
return listDirectory(filePath);
}
var file = fs.readFile(filePath);
if (file.error) {
return { status: 500, body: file.error };
}
return {
status: 200,
contentType: "text/html",
body: file.data
};
}
return { status: 405, body: "Method Not Allowed" };
});
}
function listDirectory(path) {
var result = fs.readDir(path);
if (result.error) {
return { status: 500, body: result.error };
}
var html = "<html><body><h1>Directory Listing</h1><ul>";
for (var i = 0; i < result.entries.length; i++) {
var e = result.entries[i];
html += "<li><a href='" + e.name + "'>" + e.name + "</a></li>";
}
html += "</ul></body></html>";
return { status: 200, contentType: "text/html", body: html };
}
```
### JSON API 服务
```javascript
function metadata() {
return {
name: "api-server",
version: "1.0.0",
type: "app",
description: "JSON API server"
};
}
var counter = 0;
function handleConn(conn) {
http.serve(conn, function(req) {
if (req.path === "/api/status") {
return {
status: 200,
body: http.json({
status: "ok",
counter: counter++,
timestamp: Date.now()
})
};
}
if (req.path === "/api/echo" && req.method === "POST") {
return {
status: 200,
body: http.json({
received: req.body
})
};
}
return {
status: 404,
body: http.json({ error: "Not Found" })
};
});
}
```
---
## 插件签名
为了安全JS 插件需要官方签名才能运行。
### 签名格式
签名文件 (`.sig`) 包含 Base64 编码的签名数据:
```json
{
"payload": {
"name": "plugin-name",
"version": "1.0.0",
"checksum": "sha256-hash",
"key_id": "official-v1"
},
"signature": "base64-signature"
}
```
### 获取签名
1. 提交插件到官方仓库
2. 通过审核后获得签名
3.`.js``.sig` 文件一起分发
---
## 发布到商店
### 商店 JSON 格式
插件商店使用 `store.json` 文件索引所有插件:
```json
[
{
"name": "echo",
"version": "1.0.0",
"type": "app",
"description": "Echo service plugin",
"author": "GoTunnel",
"icon": "https://example.com/icon.png",
"download_url": "https://example.com/plugins/echo.js"
}
]
```
### 提交流程
1. Fork 官方插件仓库
2. 添加插件文件到 `plugins/` 目录
3. 更新 `store.json`
4. 提交 Pull Request
5. 等待审核和签名
---
## 沙箱限制
为了安全JS 插件运行在沙箱环境中:
| 限制项 | 默认值 |
|--------|--------|
| 最大读取文件大小 | 10 MB |
| 最大写入文件大小 | 10 MB |
| 允许读取路径 | 插件数据目录 |
| 允许写入路径 | 插件数据目录 |
---
## 调试技巧
### 日志输出
使用 `log()` 函数输出调试信息:
```javascript
log("Debug: variable = " + JSON.stringify(variable));
```
### 错误处理
始终检查 API 返回的错误:
```javascript
var result = fs.readFile(path);
if (result.error) {
log("Error reading file: " + result.error);
return;
}
```
### 配置测试
在 Web 控制台的插件管理页面安装并配置插件,或通过 API 安装:
```bash
# 安装 JS 插件到客户端
POST /api/client/{id}/plugin/js/install
Content-Type: application/json
{
"plugin_name": "my-plugin",
"source": "function metadata() {...}",
"rule_name": "my-rule",
"remote_port": 8080,
"config": {"debug": "true"},
"auto_start": true
}
```
---
## 常见问题
**Q: 插件无法加载?**
A: 检查签名文件是否存在且有效。
**Q: 文件操作失败?**
A: 确认路径在沙箱允许范围内。
**Q: 如何获取客户端 IP**
A: 目前 API 不支持,计划在后续版本添加。
---
## 更新日志
### v1.0.0
- 初始版本
- 支持基础 API: log, config
- 支持连接 API: Read, Write, Close
- 支持文件系统 API: fs.*
- 支持 HTTP API: http.*

View File

@@ -17,7 +17,7 @@ GoTunnel 是一个类似 frp 的内网穿透解决方案,核心特点是**服
| TLS 证书 | 自动生成,零配置 | 需手动配置 |
| 管理界面 | 内置 Web 控制台 (naive-ui) | 需额外部署 Dashboard |
| 客户端部署 | 仅需 2 个参数 | 需配置文件 |
| 客户端 ID | 可选,服务端自动分配 | 需手动配置 |
| 客户端 ID | 自动根据设备标识计算 | 需手动配置 |
### 架构设计
@@ -111,14 +111,9 @@ go build -o client ./cmd/client
### 客户端启动
```bash
# 最简启动ID 由服务端自动分配
# 最简启动ID 由客户端根据设备标识自动计算
./client -s <服务器IP>:7000 -t <Token>
# 指定客户端 ID
./client -s <服务器IP>:7000 -t <Token> -id <客户端ID>
# 禁用 TLS需服务端也禁用
./client -s <服务器IP>:7000 -t <Token> -no-tls
```
**参数说明:**
@@ -127,9 +122,6 @@ go build -o client ./cmd/client
|------|------|------|
| `-s` | 服务器地址 (ip:port) | 是 |
| `-t` | 认证 Token | 是 |
| `-id` | 客户端 ID | 否(服务端自动分配) |
| `-no-tls` | 禁用 TLS 加密 | 否 |
| `-skip-verify` | 跳过证书验证(不安全,仅测试用) | 否 |
## 配置系统
@@ -388,7 +380,7 @@ curl -X POST http://server:7500/api/clients \
-d '{"id":"home","rules":[{"name":"web","type":"tcp","local_ip":"127.0.0.1","local_port":80,"remote_port":8080}]}'
# 客户端连接
./client -s server:7000 -t <token> -id home
./client -s server:7000 -t <token>
# 访问http://server:8080 -> 内网 127.0.0.1:80
```
@@ -411,7 +403,7 @@ A: 在 Web 控制台点击客户端详情,进入编辑模式即可设置昵称
**Q: 如何禁用 TLS**
A: 服务端配置 `tls_disabled: true`,客户端使用 `-no-tls` 参数
A: 客户端命令行默认使用 TLS如需兼容旧的非 TLS 部署,请改用客户端配置文件中的 `no_tls: true`
**Q: 端口被占用怎么办?**
@@ -419,7 +411,7 @@ A: 服务端会自动检测端口冲突,请检查日志并更换端口。
**Q: 客户端 ID 是如何分配的?**
A: 如果客户端未指定 `-id` 参数,服务端会自动生成 16 位随机 ID。
A: 客户端会把系统机器 ID、全部可用 MAC、主机名和网卡名等稳定标识组合后再进行哈希得到固定客户端 ID服务端不再为客户端分配或修正 ID。
**Q: 如何更新服务端/客户端?**

5
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/.gradle
/build
/app/build
/local.properties
/captures

27
android/README.md Normal file
View File

@@ -0,0 +1,27 @@
# GoTunnel Android Host
This directory contains a minimal Android Studio / Gradle project skeleton for the GoTunnel Android host app.
## What is included
- Foreground service shell for keeping the tunnel process alive
- Boot receiver for auto-start on device reboot
- Network recovery helper for reconnect/restart triggers
- Basic configuration screen for server address and token
- Notification channel and ongoing service notification
- A stub bridge layer that can later be replaced with a gomobile/native Go core binding
## Current status
The Go tunnel core is not wired into Android yet. `GoTunnelBridge` returns a stub controller so the app structure can be developed independently from the Go runtime integration.
## Open in Android Studio
Open the `android/` folder as a Gradle project. Android Studio can sync it directly and generate a wrapper if you want to build from the command line later.
## Notes
- The foreground service is marked as `dataSync` and starts in sticky mode.
- Auto-start is controlled by the saved configuration.
- Network restoration currently triggers a restart hook in the stub controller.
- Replace the stub bridge with a native binding when the Go client core is exported for Android.

View File

@@ -0,0 +1,48 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.gotunnel.android"
compileSdk = 34
defaultConfig {
applicationId = "com.gotunnel.android"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
viewBinding = true
}
}
dependencies {
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.activity:activity-ktx:1.9.2")
implementation("com.google.android.material:material:1.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}

2
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,2 @@
# Placeholder rules for the Android host shell.
# Add Go bridge / native binding rules here when the core integration is introduced.

View File

@@ -0,0 +1,51 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:name=".GoTunnelApp"
android:allowBackup="true"
android:icon="@drawable/ic_gotunnel_app"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_gotunnel_app"
android:supportsRtl="true"
android:theme="@style/Theme.GoTunnel">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".SettingsActivity"
android:exported="false" />
<service
android:name=".service.TunnelService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:stopWithTask="false" />
<receiver
android:name=".service.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,11 @@
package com.gotunnel.android
import android.app.Application
import com.gotunnel.android.service.NotificationHelper
class GoTunnelApp : Application() {
override fun onCreate() {
super.onCreate()
NotificationHelper.ensureChannel(this)
}
}

View File

@@ -0,0 +1,132 @@
package com.gotunnel.android
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.gotunnel.android.bridge.TunnelStatus
import com.gotunnel.android.config.ConfigStore
import com.gotunnel.android.config.LogStore
import com.gotunnel.android.config.ServiceStateStore
import com.gotunnel.android.databinding.ActivityMainBinding
import com.gotunnel.android.service.TunnelService
import java.text.DateFormat
import java.util.Date
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var configStore: ConfigStore
private lateinit var stateStore: ServiceStateStore
private lateinit var logStore: LogStore
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (!granted) {
Toast.makeText(this, R.string.notification_permission_denied, Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestNotificationPermissionIfNeeded()
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
configStore = ConfigStore(this)
stateStore = ServiceStateStore(this)
logStore = LogStore(this)
binding.topToolbar.setNavigationOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java))
}
binding.startButton.setOnClickListener {
val config = configStore.load()
if (config.serverAddress.isBlank() || config.token.isBlank()) {
Toast.makeText(this, R.string.config_missing, Toast.LENGTH_SHORT).show()
startActivity(Intent(this, SettingsActivity::class.java))
return@setOnClickListener
}
ContextCompat.startForegroundService(
this,
TunnelService.createStartIntent(this, "manual-start"),
)
Toast.makeText(this, R.string.service_start_requested, Toast.LENGTH_SHORT).show()
}
binding.stopButton.setOnClickListener {
ContextCompat.startForegroundService(
this,
TunnelService.createStopIntent(this, "manual-stop"),
)
Toast.makeText(this, R.string.service_stop_requested, Toast.LENGTH_SHORT).show()
}
}
override fun onResume() {
super.onResume()
renderScreen()
}
private fun renderScreen() {
val config = configStore.load()
val state = stateStore.load()
val timestamp = if (state.updatedAt > 0L) {
DateFormat.getDateTimeInstance().format(Date(state.updatedAt))
} else {
getString(R.string.state_never_updated)
}
binding.statusValue.text = getStatusLabel(state.status)
binding.statusDetail.text = state.detail.ifBlank { getString(R.string.state_no_detail) }
binding.stateMeta.text = getString(R.string.state_meta_format, timestamp)
binding.stateHint.text = getStateHint(state.status)
binding.serverSummary.text = if (config.serverAddress.isBlank()) {
getString(R.string.status_server_unconfigured)
} else {
getString(R.string.status_server_configured, config.serverAddress)
}
binding.logValue.text = logStore.render()
}
private fun getStatusLabel(status: TunnelStatus): String {
return when (status) {
TunnelStatus.RUNNING -> getString(R.string.status_running)
TunnelStatus.STARTING -> getString(R.string.status_starting)
TunnelStatus.RECONNECTING -> getString(R.string.status_reconnecting)
TunnelStatus.ERROR -> getString(R.string.status_error)
TunnelStatus.STOPPED -> getString(R.string.status_stopped)
}
}
private fun getStateHint(status: TunnelStatus): String {
val messageId = when (status) {
TunnelStatus.RUNNING -> R.string.state_hint_running
TunnelStatus.STARTING -> R.string.state_hint_starting
TunnelStatus.RECONNECTING -> R.string.state_hint_reconnecting
TunnelStatus.ERROR -> R.string.state_hint_error
TunnelStatus.STOPPED -> R.string.state_hint_stopped
}
return getString(messageId)
}
private fun requestNotificationPermissionIfNeeded() {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
return
}
val granted = ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
if (!granted) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}

View File

@@ -0,0 +1,70 @@
package com.gotunnel.android
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.gotunnel.android.config.AppConfig
import com.gotunnel.android.config.ConfigStore
import com.gotunnel.android.databinding.ActivitySettingsBinding
class SettingsActivity : AppCompatActivity() {
private lateinit var binding: ActivitySettingsBinding
private lateinit var configStore: ConfigStore
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
configStore = ConfigStore(this)
populateForm(configStore.load())
binding.topToolbar.setNavigationOnClickListener {
finish()
}
binding.saveButton.setOnClickListener {
configStore.save(readForm())
Toast.makeText(this, R.string.config_saved, Toast.LENGTH_SHORT).show()
finish()
}
binding.batteryButton.setOnClickListener {
openBatteryOptimizationSettings()
}
}
private fun populateForm(config: AppConfig) {
binding.serverAddressInput.setText(config.serverAddress)
binding.tokenInput.setText(config.token)
binding.autoStartSwitch.isChecked = config.autoStart
binding.autoReconnectSwitch.isChecked = config.autoReconnect
}
private fun readForm(): AppConfig {
return AppConfig(
serverAddress = binding.serverAddressInput.text?.toString().orEmpty().trim(),
token = binding.tokenInput.text?.toString().orEmpty().trim(),
autoStart = binding.autoStartSwitch.isChecked,
autoReconnect = binding.autoReconnectSwitch.isChecked,
)
}
private fun openBatteryOptimizationSettings() {
val powerManager = getSystemService(PowerManager::class.java)
if (powerManager != null && powerManager.isIgnoringBatteryOptimizations(packageName)) {
Toast.makeText(this, R.string.battery_optimization_already_disabled, Toast.LENGTH_SHORT).show()
return
}
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
startActivity(intent)
}
}

View File

@@ -0,0 +1,10 @@
package com.gotunnel.android.bridge
import android.content.Context
object GoTunnelBridge {
fun create(context: Context): TunnelController {
// Stub bridge for the Android shell. Replace with a native Go binding later.
return StubTunnelController(context.applicationContext)
}
}

View File

@@ -0,0 +1,64 @@
package com.gotunnel.android.bridge
import android.content.Context
import com.gotunnel.android.config.AppConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class StubTunnelController(
@Suppress("unused") private val context: Context,
) : TunnelController {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var listener: TunnelController.Listener? = null
private var config: AppConfig = AppConfig()
private var job: Job? = null
override val isRunning: Boolean
get() = job?.isActive == true
override fun setListener(listener: TunnelController.Listener?) {
this.listener = listener
}
override fun updateConfig(config: AppConfig) {
this.config = config
}
override fun start(config: AppConfig) {
updateConfig(config)
if (isRunning) {
listener?.onLog("Stub tunnel already running")
return
}
job = scope.launch {
listener?.onStatusChanged(TunnelStatus.STARTING, "Preparing tunnel session")
delay(400)
listener?.onLog("Stub tunnel prepared for ${config.serverAddress}")
listener?.onStatusChanged(TunnelStatus.RUNNING, "Waiting for native Go core")
while (isActive) {
delay(30_000)
listener?.onLog("Stub keepalive tick for ${this@StubTunnelController.config.serverAddress}")
}
}
}
override fun stop(reason: String) {
listener?.onLog("Stub tunnel stop requested: $reason")
job?.cancel()
job = null
listener?.onStatusChanged(TunnelStatus.STOPPED, reason)
}
override fun restart(reason: String) {
listener?.onStatusChanged(TunnelStatus.RECONNECTING, reason)
stop(reason)
start(config)
}
}

View File

@@ -0,0 +1,26 @@
package com.gotunnel.android.bridge
import com.gotunnel.android.config.AppConfig
enum class TunnelStatus {
STOPPED,
STARTING,
RUNNING,
RECONNECTING,
ERROR,
}
interface TunnelController {
interface Listener {
fun onStatusChanged(status: TunnelStatus, detail: String = "")
fun onLog(message: String)
}
val isRunning: Boolean
fun setListener(listener: Listener?)
fun updateConfig(config: AppConfig)
fun start(config: AppConfig)
fun stop(reason: String = "manual")
fun restart(reason: String = "manual")
}

View File

@@ -0,0 +1,8 @@
package com.gotunnel.android.config
data class AppConfig(
val serverAddress: String = "",
val token: String = "",
val autoStart: Boolean = true,
val autoReconnect: Boolean = true,
)

View File

@@ -0,0 +1,35 @@
package com.gotunnel.android.config
import android.content.Context
class ConfigStore(context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun load(): AppConfig {
return AppConfig(
serverAddress = prefs.getString(KEY_SERVER_ADDRESS, "") ?: "",
token = prefs.getString(KEY_TOKEN, "") ?: "",
autoStart = prefs.getBoolean(KEY_AUTO_START, true),
autoReconnect = prefs.getBoolean(KEY_AUTO_RECONNECT, true),
)
}
fun save(config: AppConfig) {
prefs.edit()
.putString(KEY_SERVER_ADDRESS, config.serverAddress)
.putString(KEY_TOKEN, config.token)
.putBoolean(KEY_AUTO_START, config.autoStart)
.putBoolean(KEY_AUTO_RECONNECT, config.autoReconnect)
.remove(KEY_USE_TLS)
.apply()
}
companion object {
private const val PREFS_NAME = "gotunnel_config"
private const val KEY_SERVER_ADDRESS = "server_address"
private const val KEY_TOKEN = "token"
private const val KEY_AUTO_START = "auto_start"
private const val KEY_AUTO_RECONNECT = "auto_reconnect"
private const val KEY_USE_TLS = "use_tls"
}
}

View File

@@ -0,0 +1,52 @@
package com.gotunnel.android.config
import android.content.Context
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class LogStore(context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun append(message: String) {
if (message.isBlank()) {
return
}
val current = load().toMutableList()
current += "${timestamp()} $message"
while (current.size > MAX_LINES) {
current.removeAt(0)
}
prefs.edit().putString(KEY_LOGS, current.joinToString(SEPARATOR)).apply()
}
fun load(): List<String> {
val raw = prefs.getString(KEY_LOGS, "") ?: ""
if (raw.isBlank()) {
return emptyList()
}
return raw.split(SEPARATOR).filter { it.isNotBlank() }
}
fun render(): String {
val lines = load()
return if (lines.isEmpty()) {
"No logs yet."
} else {
lines.joinToString("\n")
}
}
private fun timestamp(): String {
return SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
}
companion object {
private const val PREFS_NAME = "gotunnel_logs"
private const val KEY_LOGS = "logs"
private const val MAX_LINES = 80
private const val SEPARATOR = "\u0001"
}
}

View File

@@ -0,0 +1,40 @@
package com.gotunnel.android.config
import android.content.Context
import com.gotunnel.android.bridge.TunnelStatus
data class ServiceState(
val status: TunnelStatus = TunnelStatus.STOPPED,
val detail: String = "",
val updatedAt: Long = 0L,
)
class ServiceStateStore(context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun load(): ServiceState {
val statusName = prefs.getString(KEY_STATUS, TunnelStatus.STOPPED.name) ?: TunnelStatus.STOPPED.name
val status = runCatching { TunnelStatus.valueOf(statusName) }.getOrDefault(TunnelStatus.STOPPED)
return ServiceState(
status = status,
detail = prefs.getString(KEY_DETAIL, "") ?: "",
updatedAt = prefs.getLong(KEY_UPDATED_AT, 0L),
)
}
fun save(status: TunnelStatus, detail: String) {
prefs.edit()
.putString(KEY_STATUS, status.name)
.putString(KEY_DETAIL, detail)
.putLong(KEY_UPDATED_AT, System.currentTimeMillis())
.apply()
}
companion object {
private const val PREFS_NAME = "gotunnel_state"
private const val KEY_STATUS = "status"
private const val KEY_DETAIL = "detail"
private const val KEY_UPDATED_AT = "updated_at"
}
}

View File

@@ -0,0 +1,26 @@
package com.gotunnel.android.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import com.gotunnel.android.config.ConfigStore
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action ?: return
if (action != Intent.ACTION_BOOT_COMPLETED && action != Intent.ACTION_MY_PACKAGE_REPLACED) {
return
}
val config = ConfigStore(context).load()
if (!config.autoStart) {
return
}
ContextCompat.startForegroundService(
context,
TunnelService.createStartIntent(context, action.lowercase()),
)
}
}

View File

@@ -0,0 +1,52 @@
package com.gotunnel.android.service
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
class NetworkMonitor(
context: Context,
private val onAvailable: () -> Unit,
private val onLost: () -> Unit = {},
) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private var registered = false
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
onAvailable()
}
override fun onLost(network: Network) {
onLost()
}
}
fun start() {
if (registered) {
return
}
connectivityManager.registerDefaultNetworkCallback(callback)
registered = true
}
fun isConnected(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
fun stop() {
if (!registered) {
return
}
runCatching {
connectivityManager.unregisterNetworkCallback(callback)
}
registered = false
}
}

View File

@@ -0,0 +1,106 @@
package com.gotunnel.android.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import com.gotunnel.android.MainActivity
import com.gotunnel.android.R
import com.gotunnel.android.bridge.TunnelStatus
import com.gotunnel.android.config.AppConfig
object NotificationHelper {
const val CHANNEL_ID = "gotunnel_tunnel"
const val NOTIFICATION_ID = 2001
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = context.getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(CHANNEL_ID) != null) {
return
}
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = context.getString(R.string.notification_channel_description)
}
manager.createNotificationChannel(channel)
}
fun build(
context: Context,
status: TunnelStatus,
detail: String,
config: AppConfig,
): Notification {
val baseText = when {
detail.isNotBlank() -> detail
config.serverAddress.isNotBlank() -> context.getString(
R.string.notification_text_configured,
config.serverAddress,
)
else -> context.getString(R.string.notification_text_unconfigured)
}
val contentIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
pendingIntentFlags(),
)
val stopIntent = PendingIntent.getService(
context,
1,
TunnelService.createStopIntent(context, "notification-stop"),
pendingIntentFlags(),
)
val restartIntent = PendingIntent.getService(
context,
2,
TunnelService.createRestartIntent(context, "notification-restart"),
pendingIntentFlags(),
)
return NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_gotunnel_notification)
.setContentTitle(context.getString(R.string.notification_title, statusLabel(context, status)))
.setContentText(baseText)
.setStyle(NotificationCompat.BigTextStyle().bigText(baseText))
.setOngoing(status != TunnelStatus.STOPPED)
.setOnlyAlertOnce(true)
.setContentIntent(contentIntent)
.addAction(android.R.drawable.ic_popup_sync, context.getString(R.string.notification_action_restart), restartIntent)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, context.getString(R.string.notification_action_stop), stopIntent)
.build()
}
private fun statusLabel(context: Context, status: TunnelStatus): String {
return when (status) {
TunnelStatus.RUNNING -> context.getString(R.string.status_running)
TunnelStatus.STARTING -> context.getString(R.string.status_starting)
TunnelStatus.RECONNECTING -> context.getString(R.string.status_reconnecting)
TunnelStatus.ERROR -> context.getString(R.string.status_error)
TunnelStatus.STOPPED -> context.getString(R.string.status_stopped)
}
}
private fun pendingIntentFlags(): Int {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
}
}

View File

@@ -0,0 +1,180 @@
package com.gotunnel.android.service
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationManagerCompat
import com.gotunnel.android.bridge.GoTunnelBridge
import com.gotunnel.android.bridge.TunnelController
import com.gotunnel.android.bridge.TunnelStatus
import com.gotunnel.android.config.AppConfig
import com.gotunnel.android.config.ConfigStore
import com.gotunnel.android.config.LogStore
import com.gotunnel.android.config.ServiceStateStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
class TunnelService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private lateinit var configStore: ConfigStore
private lateinit var stateStore: ServiceStateStore
private lateinit var logStore: LogStore
private lateinit var controller: TunnelController
private lateinit var networkMonitor: NetworkMonitor
private var currentConfig: AppConfig = AppConfig()
private var networkMonitorPrimed = false
override fun onCreate() {
super.onCreate()
configStore = ConfigStore(this)
stateStore = ServiceStateStore(this)
logStore = LogStore(this)
controller = GoTunnelBridge.create(applicationContext)
controller.setListener(object : TunnelController.Listener {
override fun onStatusChanged(status: TunnelStatus, detail: String) {
stateStore.save(status, detail)
logStore.append("status: ${status.name} ${detail.ifBlank { "" }}".trim())
updateNotification(status, detail)
}
override fun onLog(message: String) {
val current = stateStore.load()
logStore.append(message)
updateNotification(current.status, message)
}
})
networkMonitor = NetworkMonitor(
this,
onAvailable = {
if (networkMonitorPrimed) {
networkMonitorPrimed = false
} else {
val config = configStore.load()
if (config.autoReconnect && controller.isRunning) {
controller.restart("network-restored")
}
}
},
onLost = {
val detail = getString(com.gotunnel.android.R.string.network_lost)
stateStore.save(TunnelStatus.RECONNECTING, detail)
logStore.append(detail)
updateNotification(TunnelStatus.RECONNECTING, detail)
},
)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
ensureForeground()
when (intent?.action) {
ACTION_STOP -> {
stopServiceInternal(intent.getStringExtra(EXTRA_REASON) ?: "stop")
return START_NOT_STICKY
}
ACTION_RESTART -> {
controller.restart(intent.getStringExtra(EXTRA_REASON) ?: "restart")
}
else -> {
startOrRefreshTunnel(intent?.getStringExtra(EXTRA_REASON) ?: "start")
}
}
return START_STICKY
}
override fun onDestroy() {
runCatching { networkMonitor.stop() }
runCatching { controller.stop("service-destroyed") }
serviceScope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun ensureForeground() {
val state = stateStore.load()
val config = configStore.load()
NotificationHelper.ensureChannel(this)
startForeground(
NotificationHelper.NOTIFICATION_ID,
NotificationHelper.build(this, state.status, state.detail, config),
)
}
private fun startOrRefreshTunnel(reason: String) {
currentConfig = configStore.load()
controller.updateConfig(currentConfig)
stateStore.save(TunnelStatus.STARTING, reason)
logStore.append("start requested: $reason")
updateNotification(TunnelStatus.STARTING, reason)
if (!isConfigReady(currentConfig)) {
val detail = getString(com.gotunnel.android.R.string.config_missing)
stateStore.save(TunnelStatus.STOPPED, detail)
logStore.append(detail)
updateNotification(TunnelStatus.STOPPED, detail)
return
}
networkMonitorPrimed = networkMonitor.isConnected()
controller.start(currentConfig)
runCatching { networkMonitor.start() }
}
private fun stopServiceInternal(reason: String) {
runCatching { networkMonitor.stop() }
networkMonitorPrimed = false
controller.stop(reason)
stateStore.save(TunnelStatus.STOPPED, reason)
logStore.append("stop requested: $reason")
updateNotification(TunnelStatus.STOPPED, reason)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun updateNotification(status: TunnelStatus, detail: String) {
val config = currentConfig.takeIf { it.serverAddress.isNotBlank() } ?: configStore.load()
NotificationManagerCompat.from(this).notify(
NotificationHelper.NOTIFICATION_ID,
NotificationHelper.build(this, status, detail, config),
)
}
private fun isConfigReady(config: AppConfig): Boolean {
return config.serverAddress.isNotBlank() && config.token.isNotBlank()
}
companion object {
const val ACTION_START = "com.gotunnel.android.service.action.START"
const val ACTION_STOP = "com.gotunnel.android.service.action.STOP"
const val ACTION_RESTART = "com.gotunnel.android.service.action.RESTART"
const val EXTRA_REASON = "extra_reason"
fun createStartIntent(context: Context, reason: String): Intent {
return Intent(context, TunnelService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_REASON, reason)
}
}
fun createStopIntent(context: Context, reason: String): Intent {
return Intent(context, TunnelService::class.java).apply {
action = ACTION_STOP
putExtra(EXTRA_REASON, reason)
}
}
fun createRestartIntent(context: Context, reason: String): Intent {
return Intent(context, TunnelService::class.java).apply {
action = ACTION_RESTART
putExtra(EXTRA_REASON, reason)
}
}
}
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0EA5A8"
android:pathData="M54,10a44,44 0 1,0 0,88a44,44 0 1,0 0,-88z" />
<path
android:fillColor="#0F172A"
android:pathData="M39,35h30c4.4,0 8,3.6 8,8v22c0,4.4 -3.6,8 -8,8H39c-4.4,0 -8,-3.6 -8,-8V43c0,-4.4 3.6,-8 8,-8z" />
<path
android:fillColor="#E5E7EB"
android:pathData="M44,43h20c2.2,0 4,1.8 4,4v14c0,2.2 -1.8,4 -4,4H44c-2.2,0 -4,-1.8 -4,-4V47c0,-2.2 1.8,-4 4,-4z" />
<path
android:fillColor="#38BDF8"
android:pathData="M52,49l10,6l-10,6z" />
</vector>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20z" />
<path
android:fillColor="#0EA5A8"
android:pathData="M8,8h8c1.1,0 2,0.9 2,2v4c0,1.1 -0.9,2 -2,2H8c-1.1,0 -2,-0.9 -2,-2v-4c0,-1.1 0.9,-2 2,-2z" />
<path
android:fillColor="#0F172A"
android:pathData="M10,10l4,2l-4,2z" />
</vector>

View File

@@ -0,0 +1,223 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/gotunnel_background"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/topToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
app:navigationIcon="@android:drawable/ic_menu_manage"
app:navigationIconTint="@color/gotunnel_text"
app:subtitle="@string/main_subtitle"
app:subtitleTextColor="@color/gotunnel_text_muted"
app:title="@string/home_title"
app:titleTextColor="@color/gotunnel_text" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:cardBackgroundColor="@color/gotunnel_surface"
app:cardCornerRadius="24dp"
app:strokeColor="@color/gotunnel_border"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status_card_title"
android:textColor="@color/gotunnel_text_muted"
android:textSize="13sp" />
<TextView
android:id="@+id/statusValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/gotunnel_text"
android:textSize="30sp"
android:textStyle="bold" />
<TextView
android:id="@+id/statusDetail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/gotunnel_text"
android:textSize="15sp" />
<TextView
android:id="@+id/stateHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/gotunnel_text_muted"
android:textSize="14sp" />
<TextView
android:id="@+id/serverSummary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:textColor="@color/gotunnel_text"
android:textSize="14sp" />
<TextView
android:id="@+id/stateMeta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@color/gotunnel_text_muted"
android:textSize="12sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/startButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/start_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/stopButton"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:text="@string/stop_button" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
app:cardBackgroundColor="@color/gotunnel_surface"
app:cardCornerRadius="24dp"
app:strokeColor="@color/gotunnel_border"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/log_card_title"
android:textColor="@color/gotunnel_text"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/logValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:fontFamily="monospace"
android:lineSpacingExtra="4dp"
android:textColor="@color/gotunnel_text"
android:textIsSelectable="true"
android:textSize="13sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
app:cardBackgroundColor="@color/gotunnel_surface"
app:cardCornerRadius="24dp"
app:strokeColor="@color/gotunnel_border"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/proxy_card_title"
android:textColor="@color/gotunnel_text"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/proxy_card_subtitle"
android:textColor="@color/gotunnel_text_muted"
android:textSize="13sp" />
<com.google.android.material.chip.ChipGroup
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
app:chipSpacingHorizontal="8dp"
app:chipSpacingVertical="8dp"
app:singleLine="false">
<com.google.android.material.chip.Chip
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="TCP" />
<com.google.android.material.chip.Chip
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="UDP" />
<com.google.android.material.chip.Chip
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="HTTP" />
<com.google.android.material.chip.Chip
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="HTTPS" />
<com.google.android.material.chip.Chip
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="SOCKS5" />
</com.google.android.material.chip.ChipGroup>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@@ -0,0 +1,156 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/gotunnel_background"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/topToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
app:navigationIcon="@android:drawable/ic_media_previous"
app:navigationIconTint="@color/gotunnel_text"
app:subtitle="@string/settings_subtitle"
app:subtitleTextColor="@color/gotunnel_text_muted"
app:title="@string/settings_title"
app:titleTextColor="@color/gotunnel_text" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:cardBackgroundColor="@color/gotunnel_surface"
app:cardCornerRadius="24dp"
app:strokeColor="@color/gotunnel_border"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_basic_title"
android:textColor="@color/gotunnel_text"
android:textSize="18sp"
android:textStyle="bold" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:hint="@string/server_address_label"
app:boxBackgroundMode="outline"
app:helperText="@string/server_address_helper">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/serverAddressInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:hint="@string/token_label"
app:boxBackgroundMode="outline"
app:helperText="@string/token_helper">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/tokenInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
app:cardBackgroundColor="@color/gotunnel_surface"
app:cardCornerRadius="24dp"
app:strokeColor="@color/gotunnel_border"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_behavior_title"
android:textColor="@color/gotunnel_text"
android:textSize="18sp"
android:textStyle="bold" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoStartSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/auto_start_label"
android:textColor="@color/gotunnel_text" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/auto_start_helper"
android:textColor="@color/gotunnel_text_muted"
android:textSize="13sp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoReconnectSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/auto_reconnect_label"
android:textColor="@color/gotunnel_text" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/auto_reconnect_helper"
android:textColor="@color/gotunnel_text_muted"
android:textSize="13sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.button.MaterialButton
android:id="@+id/batteryButton"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/battery_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/saveButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/save_button" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@@ -0,0 +1,9 @@
<resources>
<color name="gotunnel_background">#F3F7FB</color>
<color name="gotunnel_surface">#FFFFFF</color>
<color name="gotunnel_primary">#0F766E</color>
<color name="gotunnel_secondary">#0891B2</color>
<color name="gotunnel_text">#0F172A</color>
<color name="gotunnel_text_muted">#475569</color>
<color name="gotunnel_border">#D9E2EC</color>
</resources>

View File

@@ -0,0 +1,54 @@
<resources>
<string name="app_name">GoTunnel</string>
<string name="home_title">GoTunnel Client</string>
<string name="main_subtitle">Status, recent logs, and supported proxy types</string>
<string name="settings_title">Client Settings</string>
<string name="settings_subtitle">Connection settings and startup behavior</string>
<string name="settings_basic_title">Basic Connection</string>
<string name="settings_behavior_title">Startup and Recovery</string>
<string name="server_address_label">Server Address</string>
<string name="server_address_helper">Example: 1.2.3.4:7000. This is the GoTunnel server endpoint.</string>
<string name="token_label">Access Token</string>
<string name="token_helper">The token issued by the server to identify this client.</string>
<string name="auto_start_label">Start on boot</string>
<string name="auto_start_helper">Restore the foreground service after reboot or app update.</string>
<string name="auto_reconnect_label">Reconnect when network returns</string>
<string name="auto_reconnect_helper">Restart the client automatically after connectivity is restored.</string>
<string name="save_button">Save Settings</string>
<string name="start_button">Start Client</string>
<string name="stop_button">Stop Client</string>
<string name="battery_button">Battery Optimization</string>
<string name="status_card_title">Software Status</string>
<string name="log_card_title">Recent Logs</string>
<string name="proxy_card_title">Supported Proxies</string>
<string name="proxy_card_subtitle">The current Android shell can present these proxy types. Live proxy rules can be shown after the native Go core is connected.</string>
<string name="config_saved">Client settings saved.</string>
<string name="service_start_requested">Client start requested.</string>
<string name="service_stop_requested">Client stop requested.</string>
<string name="battery_optimization_already_disabled">Battery optimization is already disabled for GoTunnel.</string>
<string name="notification_permission_denied">Notification permission was denied. Foreground service notifications may be limited.</string>
<string name="state_never_updated">Not updated yet</string>
<string name="state_no_detail">No detail available</string>
<string name="state_meta_format">Last update: %1$s</string>
<string name="state_hint_stopped">The client is idle. Save settings first, then start it from the home screen.</string>
<string name="state_hint_starting">The client is preparing a tunnel connection.</string>
<string name="state_hint_running">The client is running and ready for the native Go core to handle real proxy traffic.</string>
<string name="state_hint_reconnecting">The client is waiting for the network and will reconnect automatically.</string>
<string name="state_hint_error">The last run failed. Check the settings and recent logs.</string>
<string name="status_running">Running</string>
<string name="status_starting">Starting</string>
<string name="status_reconnecting">Reconnecting</string>
<string name="status_error">Error</string>
<string name="status_stopped">Stopped</string>
<string name="status_server_unconfigured">No server is configured yet. Open Settings to finish the basic client setup.</string>
<string name="status_server_configured">Current server: %1$s</string>
<string name="notification_channel_name">GoTunnel foreground service</string>
<string name="notification_channel_description">Keeps the GoTunnel Android client running in the foreground</string>
<string name="notification_title">GoTunnel - %1$s</string>
<string name="notification_text_configured">Current target: %1$s</string>
<string name="notification_text_unconfigured">No server configured yet</string>
<string name="notification_action_restart">Restart</string>
<string name="notification_action_stop">Stop</string>
<string name="config_missing">Please open Settings and fill in the server address and access token first.</string>
<string name="network_lost">Network connection lost</string>
</resources>

View File

@@ -0,0 +1,18 @@
<resources xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.GoTunnel" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/gotunnel_primary</item>
<item name="colorOnPrimary">@android:color/white</item>
<item name="colorSecondary">@color/gotunnel_secondary</item>
<item name="colorOnSecondary">@android:color/white</item>
<item name="colorSurface">@color/gotunnel_surface</item>
<item name="colorOnSurface">@color/gotunnel_text</item>
<item name="colorOutline">@color/gotunnel_border</item>
<item name="android:colorBackground">@color/gotunnel_background</item>
<item name="android:windowBackground">@color/gotunnel_background</item>
<item name="android:textColorPrimary">@color/gotunnel_text</item>
<item name="android:textColorSecondary">@color/gotunnel_text_muted</item>
<item name="android:statusBarColor" tools:targetApi="l">@color/gotunnel_background</item>
<item name="android:navigationBarColor" tools:targetApi="l">@color/gotunnel_background</item>
</style>
</resources>

4
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}

View File

@@ -0,0 +1,4 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "GoTunnelAndroid"
include(":app")

View File

@@ -3,15 +3,15 @@ package main
import (
"flag"
"log"
"time"
"github.com/gotunnel/internal/client/config"
"github.com/gotunnel/internal/client/tunnel"
"github.com/gotunnel/pkg/crypto"
"github.com/gotunnel/pkg/plugin"
"github.com/gotunnel/pkg/version"
)
// 版本信息(通过 ldflags 注入)
// Version information injected by ldflags.
var Version string
var BuildTime string
var GitCommit string
@@ -24,12 +24,14 @@ func init() {
func main() {
server := flag.String("s", "", "server address (ip:port)")
token := flag.String("t", "", "auth token")
id := flag.String("id", "", "client id (optional, auto-assigned if empty)")
noTLS := flag.Bool("no-tls", false, "disable TLS")
configPath := flag.String("c", "", "config file path")
dataDir := flag.String("data-dir", "", "client data directory")
clientName := flag.String("name", "", "client display name")
clientID := flag.String("id", "", "client id")
reconnectMin := flag.Int("reconnect-min", 0, "minimum reconnect delay in seconds")
reconnectMax := flag.Int("reconnect-max", 0, "maximum reconnect delay in seconds")
flag.Parse()
// 优先加载配置文件
var cfg *config.ClientConfig
if *configPath != "" {
var err error
@@ -41,41 +43,53 @@ func main() {
cfg = &config.ClientConfig{}
}
// 命令行参数覆盖配置文件
if *server != "" {
cfg.Server = *server
}
if *token != "" {
cfg.Token = *token
}
if *id != "" {
cfg.ID = *id
if *dataDir != "" {
cfg.DataDir = *dataDir
}
if *noTLS {
cfg.NoTLS = *noTLS
if *clientName != "" {
cfg.Name = *clientName
}
if *clientID != "" {
cfg.ClientID = *clientID
}
if *reconnectMin > 0 {
cfg.ReconnectMinSec = *reconnectMin
}
if *reconnectMax > 0 {
cfg.ReconnectMaxSec = *reconnectMax
}
if cfg.Server == "" || cfg.Token == "" {
log.Fatal("Usage: client [-c config.yaml] | [-s <server:port> -t <token> [-id <client_id>] [-no-tls]]")
log.Fatal("Usage: client [-c config.yaml] | [-s <server:port> -t <token>]")
}
client := tunnel.NewClient(cfg.Server, cfg.Token, cfg.ID)
opts := tunnel.ClientOptions{
DataDir: cfg.DataDir,
ClientID: cfg.ClientID,
ClientName: cfg.Name,
}
if cfg.ReconnectMinSec > 0 {
opts.ReconnectDelay = time.Duration(cfg.ReconnectMinSec) * time.Second
}
if cfg.ReconnectMaxSec > 0 {
opts.ReconnectMaxDelay = time.Duration(cfg.ReconnectMaxSec) * time.Second
}
client := tunnel.NewClientWithOptions(cfg.Server, cfg.Token, opts)
// TLS 默认启用,默认跳过证书验证(类似 frp
if !cfg.NoTLS {
client.TLSEnabled = true
client.TLSConfig = crypto.ClientTLSConfig()
log.Printf("[Client] TLS enabled")
}
// 初始化插件注册表(用于 JS 插件)
registry := plugin.NewRegistry()
client.SetPluginRegistry(registry)
// 初始化版本存储
if err := client.InitVersionStore(); err != nil {
log.Printf("[Client] Warning: failed to init version store: %v", err)
if err := client.Run(); err != nil {
log.Fatalf("Client stopped: %v", err)
}
client.Run()
}

View File

@@ -26,7 +26,6 @@ import (
"github.com/gotunnel/internal/server/db"
"github.com/gotunnel/internal/server/tunnel"
"github.com/gotunnel/pkg/crypto"
"github.com/gotunnel/pkg/plugin"
"github.com/gotunnel/pkg/version"
)
@@ -80,10 +79,8 @@ func main() {
log.Printf("[Server] TLS enabled")
}
// 初始化插件系统(用于客户端 JS 插件管理)
registry := plugin.NewRegistry()
server.SetPluginRegistry(registry)
server.SetJSPluginStore(clientStore) // 设置 JS 插件存储,用于客户端重连时恢复插件
// 设置流量存储,用于记录流量统计
server.SetTrafficStore(clientStore)
// 启动 Web 控制台
if cfg.Server.Web.Enabled {

11
go.mod
View File

@@ -3,13 +3,13 @@ module github.com/gotunnel
go 1.24.0
require (
github.com/dop251/goja v0.0.0-20251201205617-2bb4c724c0f9
github.com/gin-gonic/gin v1.11.0
github.com/go-playground/validator/v10 v10.30.1
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/yamux v0.1.1
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
github.com/shirou/gopsutil/v3 v3.24.5
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
@@ -24,9 +24,9 @@ require (
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gen2brain/shm v0.1.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
@@ -41,15 +41,16 @@ require (
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.1 // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/jezek/xgb v1.1.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -59,7 +60,7 @@ require (
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.58.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/shoenig/go-m1cpu v0.1.7 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect

29
go.sum
View File

@@ -1,7 +1,5 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
@@ -14,14 +12,12 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20251201205617-2bb4c724c0f9 h1:3uSSOd6mVlwcX3k5OYOpiDqFgRmaE2dBfLvVIFWWHrw=
github.com/dop251/goja v0.0.0-20251201205617-2bb4c724c0f9/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY=
github.com/gen2brain/shm v0.1.0/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -65,12 +61,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -85,8 +81,12 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 h1:NQYgMY188uWrS+E/7xMVpydsI48PMHcc7SfR4OxkDF4=
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018/go.mod h1:Pmpz2BLf55auQZ67u3rvyI2vAQvNetkK/4zYUmpauZQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -97,6 +97,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -122,10 +124,10 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI0=
github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w=
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -181,6 +183,7 @@ golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -213,8 +216,6 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -6,15 +6,19 @@ import (
"gopkg.in/yaml.v3"
)
// ClientConfig 客户端配置
// ClientConfig defines client runtime configuration.
type ClientConfig struct {
Server string `yaml:"server"` // 服务器地址
Token string `yaml:"token"` // 认证 Token
ID string `yaml:"id"` // 客户端 ID
NoTLS bool `yaml:"no_tls"` // 禁用 TLS
Server string `yaml:"server"`
Token string `yaml:"token"`
NoTLS bool `yaml:"no_tls"`
DataDir string `yaml:"data_dir"`
Name string `yaml:"name"`
ClientID string `yaml:"client_id"`
ReconnectMinSec int `yaml:"reconnect_min_sec"`
ReconnectMaxSec int `yaml:"reconnect_max_sec"`
}
// LoadClientConfig 加载客户端配置
// LoadClientConfig loads client configuration from YAML.
func LoadClientConfig(path string) (*ClientConfig, error) {
data, err := os.ReadFile(path)
if err != nil {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
package tunnel
import (
"os"
"path/filepath"
"runtime"
"strings"
"github.com/google/uuid"
)
const clientIDFileName = "client.id"
func resolveDataDir(explicit string) string {
if explicit != "" {
return explicit
}
if envDir := strings.TrimSpace(os.Getenv("GOTUNNEL_DATA_DIR")); envDir != "" {
return envDir
}
if configDir, err := os.UserConfigDir(); err == nil && configDir != "" {
return filepath.Join(configDir, "gotunnel")
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
return filepath.Join(home, ".gotunnel")
}
if cwd, err := os.Getwd(); err == nil && cwd != "" {
return filepath.Join(cwd, ".gotunnel")
}
return ".gotunnel"
}
func resolveClientName(explicit string) string {
if explicit != "" {
return explicit
}
if hostname, err := os.Hostname(); err == nil && hostname != "" {
return hostname
}
if runtime.GOOS == "android" {
return "android-device"
}
return "gotunnel-client"
}
func resolveClientID(dataDir, explicit string) string {
if explicit != "" {
_ = persistClientID(dataDir, explicit)
return explicit
}
if id := loadClientID(dataDir); id != "" {
return id
}
if id := getMachineID(); id != "" {
_ = persistClientID(dataDir, id)
return id
}
id := strings.ReplaceAll(uuid.NewString(), "-", "")[:16]
_ = persistClientID(dataDir, id)
return id
}
func loadClientID(dataDir string) string {
data, err := os.ReadFile(filepath.Join(dataDir, clientIDFileName))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func persistClientID(dataDir, id string) error {
if id == "" {
return nil
}
if err := os.MkdirAll(dataDir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dataDir, clientIDFileName), []byte(id+"\n"), 0600)
}

View File

@@ -0,0 +1,163 @@
package tunnel
import (
"crypto/sha256"
"encoding/hex"
"net"
"os"
"os/exec"
"runtime"
"sort"
"strings"
)
// getMachineID builds a stable fingerprint from multiple host identifiers
// and hashes the combined result into the client ID we expose externally.
func getMachineID() string {
parts := collectMachineIDParts()
if len(parts) == 0 {
return ""
}
return hashID(strings.Join(parts, "|"))
}
func collectMachineIDParts() []string {
parts := make([]string, 0, 6)
if id := getSystemMachineID(); id != "" {
parts = append(parts, "system="+id)
}
if hostname, err := os.Hostname(); err == nil && hostname != "" {
parts = append(parts, "host="+hostname)
}
if macs := getMACAddresses(); len(macs) > 0 {
parts = append(parts, "macs="+strings.Join(macs, ","))
}
if names := getInterfaceNames(); len(names) > 0 {
parts = append(parts, "ifaces="+strings.Join(names, ","))
}
if len(parts) == 0 {
return nil
}
parts = append(parts, "os="+runtime.GOOS, "arch="+runtime.GOARCH)
return parts
}
func getSystemMachineID() string {
switch runtime.GOOS {
case "linux":
return getLinuxMachineID()
case "darwin":
return getDarwinMachineID()
case "windows":
return getWindowsMachineID()
case "android":
return ""
default:
return ""
}
}
func getLinuxMachineID() string {
if data, err := os.ReadFile("/etc/machine-id"); err == nil {
return strings.TrimSpace(string(data))
}
if data, err := os.ReadFile("/var/lib/dbus/machine-id"); err == nil {
return strings.TrimSpace(string(data))
}
return ""
}
func getDarwinMachineID() string {
cmd := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice")
output, err := cmd.Output()
if err != nil {
return ""
}
for _, line := range strings.Split(string(output), "\n") {
if !strings.Contains(line, "IOPlatformUUID") {
continue
}
parts := strings.Split(line, "=")
if len(parts) != 2 {
continue
}
uuid := strings.TrimSpace(parts[1])
return strings.Trim(uuid, "\"")
}
return ""
}
func getWindowsMachineID() string {
cmd := exec.Command("reg", "query", `HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid")
output, err := cmd.Output()
if err != nil {
return ""
}
for _, line := range strings.Split(string(output), "\n") {
if !strings.Contains(line, "MachineGuid") {
continue
}
fields := strings.Fields(line)
if len(fields) >= 3 {
return fields[len(fields)-1]
}
}
return ""
}
func getMACAddresses() []string {
interfaces, err := net.Interfaces()
if err != nil {
return nil
}
macs := make([]string, 0, len(interfaces))
for _, iface := range interfaces {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
if len(iface.HardwareAddr) == 0 {
continue
}
macs = append(macs, iface.HardwareAddr.String())
}
sort.Strings(macs)
return macs
}
func getInterfaceNames() []string {
interfaces, err := net.Interfaces()
if err != nil {
return nil
}
names := make([]string, 0, len(interfaces))
for _, iface := range interfaces {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
names = append(names, iface.Name)
}
sort.Strings(names)
return names
}
func hashID(id string) string {
hash := sha256.Sum256([]byte(id))
return hex.EncodeToString(hash[:])[:16]
}

View File

@@ -0,0 +1,41 @@
package tunnel
import "time"
// PlatformFeatures controls which platform-specific capabilities the client may use.
type PlatformFeatures struct {
AllowSelfUpdate bool
AllowScreenshot bool
AllowShellExecute bool
AllowSystemStats bool
}
// ClientOptions controls optional client runtime settings.
type ClientOptions struct {
DataDir string
ClientID string
ClientName string
Features *PlatformFeatures
ReconnectDelay time.Duration
ReconnectMaxDelay time.Duration
}
// DefaultPlatformFeatures enables the desktop feature set.
func DefaultPlatformFeatures() PlatformFeatures {
return PlatformFeatures{
AllowSelfUpdate: true,
AllowScreenshot: true,
AllowShellExecute: true,
AllowSystemStats: true,
}
}
// MobilePlatformFeatures disables capabilities that are unsuitable for a mobile sandbox.
func MobilePlatformFeatures() PlatformFeatures {
return PlatformFeatures{
AllowSelfUpdate: false,
AllowScreenshot: false,
AllowShellExecute: false,
AllowSystemStats: true,
}
}

View File

@@ -16,23 +16,21 @@ var staticFiles embed.FS
// WebServer Web控制台服务
type WebServer struct {
ClientStore db.ClientStore
Server router.ServerInterface
Config *config.ServerConfig
ConfigPath string
JSPluginStore db.JSPluginStore
TrafficStore db.TrafficStore
ClientStore db.ClientStore
Server router.ServerInterface
Config *config.ServerConfig
ConfigPath string
TrafficStore db.TrafficStore
}
// NewWebServer 创建Web服务
func NewWebServer(cs db.ClientStore, srv router.ServerInterface, cfg *config.ServerConfig, cfgPath string, store db.Store) *WebServer {
return &WebServer{
ClientStore: cs,
Server: srv,
Config: cfg,
ConfigPath: cfgPath,
JSPluginStore: store,
TrafficStore: store,
ClientStore: cs,
Server: srv,
Config: cfg,
ConfigPath: cfgPath,
TrafficStore: store,
}
}
@@ -107,11 +105,6 @@ func (w *WebServer) SaveConfig() error {
return config.SaveServerConfig(w.ConfigPath, w.Config)
}
// GetJSPluginStore 获取 JS 插件存储
func (w *WebServer) GetJSPluginStore() db.JSPluginStore {
return w.JSPluginStore
}
// GetTrafficStore 获取流量存储
func (w *WebServer) GetTrafficStore() db.TrafficStore {
return w.TrafficStore

View File

@@ -13,33 +13,16 @@ type ServerConfig struct {
Server ServerSettings `yaml:"server"`
}
// PluginStoreSettings 插件仓库设置
type PluginStoreSettings struct {
URL string `yaml:"url"` // 插件仓库 URL为空则使用默认值
}
// 默认插件仓库 URL
const DefaultPluginStoreURL = "https://git.92coco.cn/flik/GoTunnel-Plugins/raw/branch/main/store.json"
// GetPluginStoreURL 获取插件仓库 URL
func (s *PluginStoreSettings) GetPluginStoreURL() string {
if s.URL != "" {
return s.URL
}
return DefaultPluginStoreURL
}
// ServerSettings 服务端设置
type ServerSettings struct {
BindAddr string `yaml:"bind_addr"`
BindPort int `yaml:"bind_port"`
Token string `yaml:"token"`
HeartbeatSec int `yaml:"heartbeat_sec"`
HeartbeatTimeout int `yaml:"heartbeat_timeout"`
DBPath string `yaml:"db_path"`
TLSDisabled bool `yaml:"tls_disabled"`
Web WebSettings `yaml:"web"`
PluginStore PluginStoreSettings `yaml:"plugin_store"`
BindAddr string `yaml:"bind_addr"`
BindPort int `yaml:"bind_port"`
Token string `yaml:"token"`
HeartbeatSec int `yaml:"heartbeat_sec"`
HeartbeatTimeout int `yaml:"heartbeat_timeout"`
DBPath string `yaml:"db_path"`
TLSDisabled bool `yaml:"tls_disabled"`
Web WebSettings `yaml:"web"`
}
// WebSettings Web控制台设置

View File

@@ -0,0 +1,45 @@
package db
// CreateInstallToken 创建安装token
func (s *SQLiteStore) CreateInstallToken(token *InstallToken) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`INSERT INTO install_tokens (token, client_id, created_at, used) VALUES (?, '', ?, ?)`,
token.Token, token.CreatedAt, 0)
return err
}
// GetInstallToken 获取安装token
func (s *SQLiteStore) GetInstallToken(token string) (*InstallToken, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var t InstallToken
var used int
err := s.db.QueryRow(`SELECT token, created_at, used FROM install_tokens WHERE token = ?`, token).
Scan(&t.Token, &t.CreatedAt, &used)
if err != nil {
return nil, err
}
t.Used = used == 1
return &t, nil
}
// MarkTokenUsed 标记token已使用
func (s *SQLiteStore) MarkTokenUsed(token string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`UPDATE install_tokens SET used = 1 WHERE token = ?`, token)
return err
}
// DeleteExpiredTokens 删除过期token
func (s *SQLiteStore) DeleteExpiredTokens(expireTime int64) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM install_tokens WHERE created_at < ?`, expireTime)
return err
}

View File

@@ -2,52 +2,11 @@ package db
import "github.com/gotunnel/pkg/protocol"
// ConfigField 配置字段定义
type ConfigField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"`
Default string `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
Options []string `json:"options,omitempty"`
Description string `json:"description,omitempty"`
}
// ClientPlugin 客户端已安装的插件
type ClientPlugin struct {
ID string `json:"id"` // 插件实例唯一 ID
Name string `json:"name"`
Version string `json:"version"`
Enabled bool `json:"enabled"`
Running bool `json:"running"` // 运行状态
Config map[string]string `json:"config,omitempty"` // 插件配置
RemotePort int `json:"remote_port,omitempty"` // 远程监听端口
ConfigSchema []ConfigField `json:"config_schema,omitempty"` // 配置模式
AuthEnabled bool `json:"auth_enabled,omitempty"` // 是否启用认证
AuthUsername string `json:"auth_username,omitempty"` // 认证用户名
AuthPassword string `json:"auth_password,omitempty"` // 认证密码
}
// Client 客户端数据
type Client struct {
ID string `json:"id"`
Nickname string `json:"nickname,omitempty"`
Rules []protocol.ProxyRule `json:"rules"`
Plugins []ClientPlugin `json:"plugins,omitempty"` // 已安装的插件
}
// JSPlugin JS 插件数据
type JSPlugin struct {
Name string `json:"name"`
Source string `json:"source"`
Signature string `json:"signature"` // 官方签名 (Base64)
Description string `json:"description"`
Author string `json:"author"`
Version string `json:"version,omitempty"`
AutoPush []string `json:"auto_push"`
Config map[string]string `json:"config"`
AutoStart bool `json:"auto_start"`
Enabled bool `json:"enabled"`
}
// ClientStore 客户端存储接口
@@ -62,20 +21,9 @@ type ClientStore interface {
Close() error
}
// JSPluginStore JS 插件存储接口
type JSPluginStore interface {
GetAllJSPlugins() ([]JSPlugin, error)
GetJSPlugin(name string) (*JSPlugin, error)
SaveJSPlugin(p *JSPlugin) error
DeleteJSPlugin(name string) error
SetJSPluginEnabled(name string, enabled bool) error
UpdateJSPluginConfig(name string, config map[string]string) error
}
// Store 统一存储接口
type Store interface {
ClientStore
JSPluginStore
TrafficStore
Close() error
}
@@ -94,3 +42,18 @@ type TrafficStore interface {
Get24HourTraffic() (inbound, outbound int64, err error)
GetHourlyTraffic(hours int) ([]TrafficRecord, error)
}
// InstallToken 安装token
type InstallToken struct {
Token string `json:"token"`
CreatedAt int64 `json:"created_at"`
Used bool `json:"used"`
}
// InstallTokenStore 安装token存储接口
type InstallTokenStore interface {
CreateInstallToken(token *InstallToken) error
GetInstallToken(token string) (*InstallToken, error)
MarkTokenUsed(token string) error
DeleteExpiredTokens(expireTime int64) error
}

View File

@@ -40,8 +40,7 @@ func (s *SQLiteStore) init() error {
CREATE TABLE IF NOT EXISTS clients (
id TEXT PRIMARY KEY,
nickname TEXT NOT NULL DEFAULT '',
rules TEXT NOT NULL DEFAULT '[]',
plugins TEXT NOT NULL DEFAULT '[]'
rules TEXT NOT NULL DEFAULT '[]'
)
`)
if err != nil {
@@ -50,36 +49,6 @@ func (s *SQLiteStore) init() error {
// 迁移:添加 nickname 列
s.db.Exec(`ALTER TABLE clients ADD COLUMN nickname TEXT NOT NULL DEFAULT ''`)
// 迁移:添加 plugins 列
s.db.Exec(`ALTER TABLE clients ADD COLUMN plugins TEXT NOT NULL DEFAULT '[]'`)
// 创建 JS 插件表
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS js_plugins (
name TEXT PRIMARY KEY,
source TEXT NOT NULL,
signature TEXT NOT NULL DEFAULT '',
description TEXT,
author TEXT,
version TEXT DEFAULT '',
auto_push TEXT NOT NULL DEFAULT '[]',
config TEXT NOT NULL DEFAULT '{}',
auto_start INTEGER DEFAULT 1,
enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return err
}
// 迁移:添加 signature 列
s.db.Exec(`ALTER TABLE js_plugins ADD COLUMN signature TEXT NOT NULL DEFAULT ''`)
// 迁移:添加 version 列
s.db.Exec(`ALTER TABLE js_plugins ADD COLUMN version TEXT DEFAULT ''`)
// 迁移:添加 updated_at 列
s.db.Exec(`ALTER TABLE js_plugins ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP`)
// 创建流量统计表
_, err = s.db.Exec(`
@@ -108,6 +77,19 @@ func (s *SQLiteStore) init() error {
// 初始化总流量记录
s.db.Exec(`INSERT OR IGNORE INTO traffic_total (id, inbound, outbound) VALUES (1, 0, 0)`)
// 创建安装token表
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS install_tokens (
token TEXT PRIMARY KEY,
client_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
used INTEGER NOT NULL DEFAULT 0
)
`)
if err != nil {
return err
}
return nil
}
@@ -121,7 +103,7 @@ func (s *SQLiteStore) GetAllClients() ([]Client, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT id, nickname, rules, plugins FROM clients`)
rows, err := s.db.Query(`SELECT id, nickname, rules FROM clients`)
if err != nil {
return nil, err
}
@@ -130,16 +112,13 @@ func (s *SQLiteStore) GetAllClients() ([]Client, error) {
var clients []Client
for rows.Next() {
var c Client
var rulesJSON, pluginsJSON string
if err := rows.Scan(&c.ID, &c.Nickname, &rulesJSON, &pluginsJSON); err != nil {
var rulesJSON string
if err := rows.Scan(&c.ID, &c.Nickname, &rulesJSON); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil {
c.Rules = []protocol.ProxyRule{}
}
if err := json.Unmarshal([]byte(pluginsJSON), &c.Plugins); err != nil {
c.Plugins = []ClientPlugin{}
}
clients = append(clients, c)
}
return clients, nil
@@ -151,17 +130,14 @@ func (s *SQLiteStore) GetClient(id string) (*Client, error) {
defer s.mu.RUnlock()
var c Client
var rulesJSON, pluginsJSON string
err := s.db.QueryRow(`SELECT id, nickname, rules, plugins FROM clients WHERE id = ?`, id).Scan(&c.ID, &c.Nickname, &rulesJSON, &pluginsJSON)
var rulesJSON string
err := s.db.QueryRow(`SELECT id, nickname, rules FROM clients WHERE id = ?`, id).Scan(&c.ID, &c.Nickname, &rulesJSON)
if err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil {
c.Rules = []protocol.ProxyRule{}
}
if err := json.Unmarshal([]byte(pluginsJSON), &c.Plugins); err != nil {
c.Plugins = []ClientPlugin{}
}
return &c, nil
}
@@ -174,12 +150,8 @@ func (s *SQLiteStore) CreateClient(c *Client) error {
if err != nil {
return err
}
pluginsJSON, err := json.Marshal(c.Plugins)
if err != nil {
return err
}
_, err = s.db.Exec(`INSERT INTO clients (id, nickname, rules, plugins) VALUES (?, ?, ?, ?)`,
c.ID, c.Nickname, string(rulesJSON), string(pluginsJSON))
_, err = s.db.Exec(`INSERT INTO clients (id, nickname, rules) VALUES (?, ?, ?)`,
c.ID, c.Nickname, string(rulesJSON))
return err
}
@@ -192,12 +164,8 @@ func (s *SQLiteStore) UpdateClient(c *Client) error {
if err != nil {
return err
}
pluginsJSON, err := json.Marshal(c.Plugins)
if err != nil {
return err
}
_, err = s.db.Exec(`UPDATE clients SET nickname = ?, rules = ?, plugins = ? WHERE id = ?`,
c.Nickname, string(rulesJSON), string(pluginsJSON), c.ID)
_, err = s.db.Exec(`UPDATE clients SET nickname = ?, rules = ? WHERE id = ?`,
c.Nickname, string(rulesJSON), c.ID)
return err
}
@@ -229,121 +197,6 @@ func (s *SQLiteStore) GetClientRules(id string) ([]protocol.ProxyRule, error) {
return c.Rules, nil
}
// ========== JS 插件存储方法 ==========
// GetAllJSPlugins 获取所有 JS 插件
func (s *SQLiteStore) GetAllJSPlugins() ([]JSPlugin, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`
SELECT name, source, signature, description, author, version, auto_push, config, auto_start, enabled
FROM js_plugins
`)
if err != nil {
return nil, err
}
defer rows.Close()
var plugins []JSPlugin
for rows.Next() {
var p JSPlugin
var autoPushJSON, configJSON string
var version sql.NullString
var autoStart, enabled int
err := rows.Scan(&p.Name, &p.Source, &p.Signature, &p.Description, &p.Author,
&version, &autoPushJSON, &configJSON, &autoStart, &enabled)
if err != nil {
return nil, err
}
p.Version = version.String
json.Unmarshal([]byte(autoPushJSON), &p.AutoPush)
json.Unmarshal([]byte(configJSON), &p.Config)
p.AutoStart = autoStart == 1
p.Enabled = enabled == 1
plugins = append(plugins, p)
}
return plugins, nil
}
// GetJSPlugin 获取单个 JS 插件
func (s *SQLiteStore) GetJSPlugin(name string) (*JSPlugin, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var p JSPlugin
var autoPushJSON, configJSON string
var version sql.NullString
var autoStart, enabled int
err := s.db.QueryRow(`
SELECT name, source, signature, description, author, version, auto_push, config, auto_start, enabled
FROM js_plugins WHERE name = ?
`, name).Scan(&p.Name, &p.Source, &p.Signature, &p.Description, &p.Author,
&version, &autoPushJSON, &configJSON, &autoStart, &enabled)
if err != nil {
return nil, err
}
p.Version = version.String
json.Unmarshal([]byte(autoPushJSON), &p.AutoPush)
json.Unmarshal([]byte(configJSON), &p.Config)
p.AutoStart = autoStart == 1
p.Enabled = enabled == 1
return &p, nil
}
// SaveJSPlugin 保存 JS 插件
func (s *SQLiteStore) SaveJSPlugin(p *JSPlugin) error {
s.mu.Lock()
defer s.mu.Unlock()
autoPushJSON, _ := json.Marshal(p.AutoPush)
configJSON, _ := json.Marshal(p.Config)
autoStart, enabled := 0, 0
if p.AutoStart {
autoStart = 1
}
if p.Enabled {
enabled = 1
}
_, err := s.db.Exec(`
INSERT OR REPLACE INTO js_plugins
(name, source, signature, description, author, version, auto_push, config, auto_start, enabled, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`, p.Name, p.Source, p.Signature, p.Description, p.Author, p.Version,
string(autoPushJSON), string(configJSON), autoStart, enabled)
return err
}
// DeleteJSPlugin 删除 JS 插件
func (s *SQLiteStore) DeleteJSPlugin(name string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM js_plugins WHERE name = ?`, name)
return err
}
// SetJSPluginEnabled 设置 JS 插件启用状态
func (s *SQLiteStore) SetJSPluginEnabled(name string, enabled bool) error {
s.mu.Lock()
defer s.mu.Unlock()
val := 0
if enabled {
val = 1
}
_, err := s.db.Exec(`UPDATE js_plugins SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?`, val, name)
return err
}
// UpdateJSPluginConfig 更新 JS 插件配置
func (s *SQLiteStore) UpdateJSPluginConfig(name string, config map[string]string) error {
s.mu.Lock()
defer s.mu.Unlock()
configJSON, _ := json.Marshal(config)
_, err := s.db.Exec(`UPDATE js_plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?`, string(configJSON), name)
return err
}
// ========== 流量统计方法 ==========
// getHourTimestamp 获取当前小时的时间戳
@@ -397,12 +250,17 @@ func (s *SQLiteStore) Get24HourTraffic() (inbound, outbound int64, err error) {
return
}
// GetHourlyTraffic 获取每小时流量记录
// GetHourlyTraffic 获取每小时流量记录(始终返回完整的 hours 小时数据)
func (s *SQLiteStore) GetHourlyTraffic(hours int) ([]TrafficRecord, error) {
s.mu.RLock()
defer s.mu.RUnlock()
cutoff := time.Now().Add(-time.Duration(hours) * time.Hour).Unix()
// 计算当前小时的起始时间戳
now := time.Now()
currentHour := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
// 查询数据库中的记录
cutoff := currentHour.Add(-time.Duration(hours-1) * time.Hour).Unix()
rows, err := s.db.Query(`
SELECT hour_ts, inbound, outbound FROM traffic_stats
WHERE hour_ts >= ? ORDER BY hour_ts ASC
@@ -412,13 +270,26 @@ func (s *SQLiteStore) GetHourlyTraffic(hours int) ([]TrafficRecord, error) {
}
defer rows.Close()
var records []TrafficRecord
// 将数据库记录放入 map 以便快速查找
dbRecords := make(map[int64]TrafficRecord)
for rows.Next() {
var r TrafficRecord
if err := rows.Scan(&r.Timestamp, &r.Inbound, &r.Outbound); err != nil {
return nil, err
}
records = append(records, r)
dbRecords[r.Timestamp] = r
}
// 生成完整的 hours 小时数据
records := make([]TrafficRecord, hours)
for i := 0; i < hours; i++ {
ts := currentHour.Add(-time.Duration(hours-1-i) * time.Hour).Unix()
if r, ok := dbRecords[ts]; ok {
records[i] = r
} else {
records[i] = TrafficRecord{Timestamp: ts, Inbound: 0, Outbound: 0}
}
}
return records, nil
}

View File

@@ -1,34 +0,0 @@
package plugin
import (
"sync"
"github.com/gotunnel/pkg/plugin"
)
// Manager 服务端 plugin 管理器
type Manager struct {
registry *plugin.Registry
mu sync.RWMutex
}
// NewManager 创建 plugin 管理器
func NewManager() (*Manager, error) {
registry := plugin.NewRegistry()
m := &Manager{
registry: registry,
}
return m, nil
}
// ListPlugins 返回所有插件
func (m *Manager) ListPlugins() []plugin.Info {
return m.registry.List()
}
// GetRegistry 返回插件注册表
func (m *Manager) GetRegistry() *plugin.Registry {
return m.registry
}

View File

@@ -1,7 +1,6 @@
package dto
import (
"github.com/gotunnel/internal/server/db"
"github.com/gotunnel/pkg/protocol"
)
@@ -17,7 +16,6 @@ type CreateClientRequest struct {
type UpdateClientRequest struct {
Nickname string `json:"nickname" binding:"max=128" example:"My Client"`
Rules []protocol.ProxyRule `json:"rules"`
Plugins []db.ClientPlugin `json:"plugins"`
}
// ClientResponse 客户端详情响应
@@ -26,7 +24,6 @@ type ClientResponse struct {
ID string `json:"id" example:"client-001"`
Nickname string `json:"nickname,omitempty" example:"My Client"`
Rules []protocol.ProxyRule `json:"rules"`
Plugins []db.ClientPlugin `json:"plugins,omitempty"`
Online bool `json:"online" example:"true"`
LastPing string `json:"last_ping,omitempty" example:"2025-01-02T10:30:00Z"`
RemoteAddr string `json:"remote_addr,omitempty" example:"192.168.1.100:54321"`
@@ -47,17 +44,3 @@ type ClientListItem struct {
OS string `json:"os,omitempty" example:"linux"`
Arch string `json:"arch,omitempty" example:"amd64"`
}
// InstallPluginsRequest 安装插件到客户端请求
// @Description 安装插件到指定客户端
type InstallPluginsRequest struct {
Plugins []string `json:"plugins" binding:"required,min=1,dive,required" example:"socks5,http-proxy"`
}
// ClientPluginActionRequest 客户端插件操作请求
// @Description 对客户端插件执行操作
type ClientPluginActionRequest struct {
RuleName string `json:"rule_name"`
Config map[string]string `json:"config,omitempty"`
Restart bool `json:"restart"`
}

View File

@@ -1,15 +1,12 @@
package dto
// UpdateServerConfigRequest 更新服务器配置请求
// @Description 更新服务器配置
// UpdateServerConfigRequest is the config update payload.
type UpdateServerConfigRequest struct {
Server *ServerConfigPart `json:"server"`
Web *WebConfigPart `json:"web"`
PluginStore *PluginStoreConfigPart `json:"plugin_store"`
Server *ServerConfigPart `json:"server"`
Web *WebConfigPart `json:"web"`
}
// ServerConfigPart 服务器配置部分
// @Description 隧道服务器配置
// ServerConfigPart is the server config subset.
type ServerConfigPart struct {
BindAddr string `json:"bind_addr" binding:"omitempty"`
BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"`
@@ -18,8 +15,7 @@ type ServerConfigPart struct {
HeartbeatTimeout int `json:"heartbeat_timeout" binding:"omitempty,min=1,max=600"`
}
// WebConfigPart Web 配置部分
// @Description Web 控制台配置
// WebConfigPart is the web console config subset.
type WebConfigPart struct {
Enabled bool `json:"enabled"`
BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"`
@@ -27,37 +23,25 @@ type WebConfigPart struct {
Password string `json:"password" binding:"omitempty,min=6,max=64"`
}
// ServerConfigResponse 服务器配置响应
// @Description 服务器配置信息
// ServerConfigResponse is the config response payload.
type ServerConfigResponse struct {
Server ServerConfigInfo `json:"server"`
Web WebConfigInfo `json:"web"`
PluginStore PluginStoreConfigInfo `json:"plugin_store"`
Server ServerConfigInfo `json:"server"`
Web WebConfigInfo `json:"web"`
}
// ServerConfigInfo 服务器配置信息
// ServerConfigInfo describes the server config.
type ServerConfigInfo struct {
BindAddr string `json:"bind_addr"`
BindPort int `json:"bind_port"`
Token string `json:"token"` // 脱敏后的 token
Token string `json:"token"`
HeartbeatSec int `json:"heartbeat_sec"`
HeartbeatTimeout int `json:"heartbeat_timeout"`
}
// WebConfigInfo Web 配置信息
// WebConfigInfo describes the web console config.
type WebConfigInfo struct {
Enabled bool `json:"enabled"`
BindPort int `json:"bind_port"`
Username string `json:"username"`
Password string `json:"password"` // 显示为 ****
}
// PluginStoreConfigPart 插件商店配置部分
type PluginStoreConfigPart struct {
URL string `json:"url"`
}
// PluginStoreConfigInfo 插件商店配置信息
type PluginStoreConfigInfo struct {
URL string `json:"url"`
Password string `json:"password"`
}

View File

@@ -1,119 +0,0 @@
package dto
// PluginConfigRequest 更新插件配置请求
// @Description 更新客户端插件配置
type PluginConfigRequest struct {
Config map[string]string `json:"config" binding:"required"`
}
// PluginConfigResponse 插件配置响应
// @Description 插件配置详情
type PluginConfigResponse struct {
PluginName string `json:"plugin_name"`
Schema []ConfigField `json:"schema"`
Config map[string]string `json:"config"`
}
// ConfigField 配置字段定义
// @Description 配置表单字段
type ConfigField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"`
Default string `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
Options []string `json:"options,omitempty"`
Description string `json:"description,omitempty"`
}
// RuleSchema 规则表单模式
// @Description 代理规则的配置模式
type RuleSchema struct {
NeedsLocalAddr bool `json:"needs_local_addr"`
ExtraFields []ConfigField `json:"extra_fields,omitempty"`
}
// PluginInfo 插件信息
// @Description 服务端插件信息
type PluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
Description string `json:"description"`
Source string `json:"source"`
Icon string `json:"icon,omitempty"`
Enabled bool `json:"enabled"`
RuleSchema *RuleSchema `json:"rule_schema,omitempty"`
}
// JSPluginCreateRequest 创建 JS 插件请求
// @Description 创建新的 JS 插件
type JSPluginCreateRequest struct {
Name string `json:"name" binding:"required,min=1,max=64"`
Source string `json:"source" binding:"required"`
Signature string `json:"signature"`
Description string `json:"description" binding:"max=500"`
Author string `json:"author" binding:"max=64"`
Config map[string]string `json:"config"`
AutoStart bool `json:"auto_start"`
}
// JSPluginUpdateRequest 更新 JS 插件请求
// @Description 更新 JS 插件
type JSPluginUpdateRequest struct {
Source string `json:"source"`
Signature string `json:"signature"`
Description string `json:"description" binding:"max=500"`
Author string `json:"author" binding:"max=64"`
Config map[string]string `json:"config"`
AutoStart bool `json:"auto_start"`
Enabled bool `json:"enabled"`
}
// JSPluginInstallRequest JS 插件安装请求
// @Description 安装 JS 插件到客户端
type JSPluginInstallRequest struct {
PluginName string `json:"plugin_name" binding:"required"`
Source string `json:"source" binding:"required"`
Signature string `json:"signature"`
RuleName string `json:"rule_name"`
RemotePort int `json:"remote_port"`
Config map[string]string `json:"config"`
AutoStart bool `json:"auto_start"`
}
// StorePluginInfo 扩展商店插件信息
// @Description 插件商店中的插件信息
type StorePluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
Description string `json:"description"`
Author string `json:"author"`
Icon string `json:"icon,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
SignatureURL string `json:"signature_url,omitempty"`
ConfigSchema []ConfigField `json:"config_schema,omitempty"`
}
// StoreInstallRequest 从商店安装插件请求
// @Description 从插件商店安装插件到客户端
type StoreInstallRequest struct {
PluginName string `json:"plugin_name" binding:"required"`
Version string `json:"version"`
DownloadURL string `json:"download_url" binding:"required,url"`
SignatureURL string `json:"signature_url" binding:"required,url"`
ClientID string `json:"client_id" binding:"required"`
RemotePort int `json:"remote_port"`
ConfigSchema []ConfigField `json:"config_schema,omitempty"`
// HTTP Basic Auth 配置
AuthEnabled bool `json:"auth_enabled,omitempty"`
AuthUsername string `json:"auth_username,omitempty"`
AuthPassword string `json:"auth_password,omitempty"`
}
// JSPluginPushRequest 推送 JS 插件到客户端请求
// @Description 推送 JS 插件到指定客户端
type JSPluginPushRequest struct {
RemotePort int `json:"remote_port"`
}

View File

@@ -6,7 +6,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/gotunnel/internal/server/db"
"github.com/gotunnel/internal/server/router/dto"
"github.com/gotunnel/pkg/protocol"
)
// ClientHandler 客户端处理器
@@ -115,41 +114,18 @@ func (h *ClientHandler) Get(c *gin.Context) {
return
}
online, lastPing, remoteAddr, clientOS, clientArch, clientVersion := h.app.GetServer().GetClientStatus(clientID)
online, lastPing, remoteAddr, clientName, clientOS, clientArch, clientVersion := h.app.GetServer().GetClientStatus(clientID)
// 复制插件列表
plugins := make([]db.ClientPlugin, len(client.Plugins))
copy(plugins, client.Plugins)
// 如果客户端在线,获取实时插件运行状态
if online {
if statusList, err := h.app.GetServer().GetClientPluginStatus(clientID); err == nil {
// 创建运行中插件的映射
runningPlugins := make(map[string]bool)
for _, s := range statusList {
runningPlugins[s.PluginName] = s.Running
}
// 更新插件状态
for i := range plugins {
if running, ok := runningPlugins[plugins[i].Name]; ok {
plugins[i].Running = running
} else {
plugins[i].Running = false
}
}
}
} else {
// 客户端离线时,所有插件都标记为未运行
for i := range plugins {
plugins[i].Running = false
}
// 如果客户端在线且有名称,优先使用在线名称
nickname := client.Nickname
if online && clientName != "" && nickname == "" {
nickname = clientName
}
resp := dto.ClientResponse{
ID: client.ID,
Nickname: client.Nickname,
Nickname: nickname,
Rules: client.Rules,
Plugins: plugins,
Online: online,
LastPing: lastPing,
RemoteAddr: remoteAddr,
@@ -190,9 +166,6 @@ func (h *ClientHandler) Update(c *gin.Context) {
client.Nickname = req.Nickname
client.Rules = req.Rules
if req.Plugins != nil {
client.Plugins = req.Plugins
}
if err := h.app.GetClientStore().UpdateClient(client); err != nil {
InternalError(c, err.Error())
@@ -242,8 +215,7 @@ func (h *ClientHandler) Delete(c *gin.Context) {
func (h *ClientHandler) PushConfig(c *gin.Context) {
clientID := c.Param("id")
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
if !h.app.GetServer().IsClientOnline(clientID) {
ClientNotOnline(c)
return
}
@@ -296,160 +268,12 @@ func (h *ClientHandler) Restart(c *gin.Context) {
SuccessWithMessage(c, gin.H{"status": "ok"}, "client restart initiated")
}
// InstallPlugins 安装插件到客户端
// @Summary 安装插件
// @Description 将指定插件安装到客户端
// @Tags 客户端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Param request body dto.InstallPluginsRequest true "插件列表"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/client/{id}/install-plugins [post]
func (h *ClientHandler) InstallPlugins(c *gin.Context) {
clientID := c.Param("id")
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
ClientNotOnline(c)
return
}
var req dto.InstallPluginsRequest
if !BindJSON(c, &req) {
return
}
if err := h.app.GetServer().InstallPluginsToClient(clientID, req.Plugins); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// PluginAction 客户端插件操作
// @Summary 插件操作
// @Description 对客户端插件执行操作(start/stop/restart/config/delete)
// @Tags 客户端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Param pluginID path string true "插件实例ID"
// @Param action path string true "操作类型" Enums(start, stop, restart, config, delete)
// @Param request body dto.ClientPluginActionRequest false "操作参数"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/client/{id}/plugin/{pluginID}/{action} [post]
func (h *ClientHandler) PluginAction(c *gin.Context) {
clientID := c.Param("id")
pluginID := c.Param("pluginID")
action := c.Param("action")
var req dto.ClientPluginActionRequest
c.ShouldBindJSON(&req) // 忽略错误,使用默认值
// 通过 pluginID 查找插件信息
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
NotFound(c, "client not found")
return
}
var pluginName string
for _, p := range client.Plugins {
if p.ID == pluginID {
pluginName = p.Name
break
}
}
if pluginName == "" {
NotFound(c, "plugin not found")
return
}
if req.RuleName == "" {
req.RuleName = pluginName
}
switch action {
case "start":
err = h.app.GetServer().StartClientPlugin(clientID, pluginID, pluginName, req.RuleName)
case "stop":
err = h.app.GetServer().StopClientPlugin(clientID, pluginID, pluginName, req.RuleName)
case "restart":
err = h.app.GetServer().RestartClientPlugin(clientID, pluginID, pluginName, req.RuleName)
case "config":
if req.Config == nil {
BadRequest(c, "config required")
return
}
err = h.app.GetServer().UpdateClientPluginConfig(clientID, pluginID, pluginName, req.RuleName, req.Config, req.Restart)
case "delete":
err = h.deleteClientPlugin(clientID, pluginID)
default:
BadRequest(c, "unknown action: "+action)
return
}
if err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{
"status": "ok",
"action": action,
"plugin_id": pluginID,
"plugin": pluginName,
})
}
func (h *ClientHandler) deleteClientPlugin(clientID, pluginID string) error {
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
return fmt.Errorf("client not found")
}
var newPlugins []db.ClientPlugin
var pluginName string
var pluginPort int
found := false
for _, p := range client.Plugins {
if p.ID == pluginID {
found = true
pluginName = p.Name
pluginPort = p.RemotePort
continue
}
newPlugins = append(newPlugins, p)
}
if !found {
return fmt.Errorf("plugin %s not found", pluginID)
}
// 删除插件管理的代理规则
var newRules []protocol.ProxyRule
for _, r := range client.Rules {
if r.PluginManaged && r.Name == pluginName {
continue // 跳过此插件的规则
}
newRules = append(newRules, r)
}
// 停止端口监听器
if pluginPort > 0 {
h.app.GetServer().StopPluginRule(clientID, pluginPort)
}
client.Plugins = newPlugins
client.Rules = newRules
return h.app.GetClientStore().UpdateClient(client)
}
// GetSystemStats 获取客户端系统状态
func (h *ClientHandler) GetSystemStats(c *gin.Context) {
@@ -462,6 +286,47 @@ func (h *ClientHandler) GetSystemStats(c *gin.Context) {
Success(c, stats)
}
// GetScreenshot 获取客户端截图
func (h *ClientHandler) GetScreenshot(c *gin.Context) {
clientID := c.Param("id")
quality := 0
if q, ok := c.GetQuery("quality"); ok {
fmt.Sscanf(q, "%d", &quality)
}
screenshot, err := h.app.GetServer().GetClientScreenshot(clientID, quality)
if err != nil {
InternalError(c, err.Error())
return
}
Success(c, screenshot)
}
// ExecuteShellRequest Shell 执行请求体
type ExecuteShellRequest struct {
Command string `json:"command" binding:"required"`
Timeout int `json:"timeout"`
}
// ExecuteShell 执行 Shell 命令
func (h *ClientHandler) ExecuteShell(c *gin.Context) {
clientID := c.Param("id")
var req ExecuteShellRequest
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, "Invalid request: "+err.Error())
return
}
result, err := h.app.GetServer().ExecuteClientShell(clientID, req.Command, req.Timeout)
if err != nil {
InternalError(c, err.Error())
return
}
Success(c, result)
}
// validateClientID 验证客户端 ID 格式
func validateClientID(id string) bool {
if len(id) < 1 || len(id) > 64 {

View File

@@ -47,9 +47,6 @@ func (h *ConfigHandler) Get(c *gin.Context) {
Username: cfg.Server.Web.Username,
Password: "****",
},
PluginStore: dto.PluginStoreConfigInfo{
URL: cfg.Server.PluginStore.URL,
},
}
Success(c, resp)
@@ -103,11 +100,6 @@ func (h *ConfigHandler) Update(c *gin.Context) {
cfg.Server.Web.Password = req.Web.Password
}
// 更新 PluginStore 配置
if req.PluginStore != nil {
cfg.Server.PluginStore.URL = req.PluginStore.URL
}
if err := h.app.SaveConfig(); err != nil {
InternalError(c, err.Error())
return

View File

@@ -165,6 +165,12 @@ func performSelfUpdate(downloadURL string, restart bool) error {
// performWindowsUpdate Windows 平台更新
func performWindowsUpdate(newFile, currentPath string, restart bool) error {
batchScript := fmt.Sprintf(`@echo off
:: Check for admin rights, request UAC elevation if needed
net session >nul 2>&1
if %%errorlevel%% neq 0 (
powershell -Command "Start-Process cmd -ArgumentList '/C \\"\"%%~f0\"\"' -Verb RunAs"
exit /b
)
ping 127.0.0.1 -n 2 > nul
del "%s"
move "%s" "%s"

View File

@@ -0,0 +1,183 @@
package handler
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gotunnel/internal/server/db"
)
type InstallHandler struct {
app AppInterface
}
const (
installTokenHeader = "X-GoTunnel-Install-Token"
installTokenTTL = 3600
)
func NewInstallHandler(app AppInterface) *InstallHandler {
return &InstallHandler{app: app}
}
type InstallCommandResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expires_at"`
TunnelPort int `json:"tunnel_port"`
}
// GenerateInstallCommand creates a one-time install token and returns
// the tunnel port so the frontend can build a host-aware command.
//
// @Summary Generate install command payload
// @Tags install
// @Produce json
// @Success 200 {object} InstallCommandResponse
// @Router /api/install/generate [post]
func (h *InstallHandler) GenerateInstallCommand(c *gin.Context) {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
return
}
token := hex.EncodeToString(tokenBytes)
now := time.Now().Unix()
installToken := &db.InstallToken{
Token: token,
CreatedAt: now,
Used: false,
}
store, ok := h.app.GetClientStore().(db.InstallTokenStore)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "install token store is not supported"})
return
}
if err := store.CreateInstallToken(installToken); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to persist token"})
return
}
c.JSON(http.StatusOK, InstallCommandResponse{
Token: token,
ExpiresAt: now + installTokenTTL,
TunnelPort: h.app.GetServer().GetBindPort(),
})
}
func (h *InstallHandler) ServeShellScript(c *gin.Context) {
if !h.validateInstallToken(c) {
return
}
applyInstallSecurityHeaders(c)
c.Header("Content-Type", "text/x-shellscript; charset=utf-8")
c.String(http.StatusOK, shellInstallScript)
}
func (h *InstallHandler) ServePowerShellScript(c *gin.Context) {
if !h.validateInstallToken(c) {
return
}
applyInstallSecurityHeaders(c)
c.Header("Content-Type", "text/plain; charset=utf-8")
c.String(http.StatusOK, powerShellInstallScript)
}
func (h *InstallHandler) DownloadClient(c *gin.Context) {
if !h.validateInstallToken(c) {
return
}
osName := c.Query("os")
arch := c.Query("arch")
updateInfo, err := checkClientUpdateForPlatform(osName, arch)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve client package"})
return
}
if updateInfo.DownloadURL == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "no client package found for this platform"})
return
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, updateInfo.DownloadURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create download request"})
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to download client package"})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("upstream returned %s", resp.Status)})
return
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
applyInstallSecurityHeaders(c)
c.Header("Content-Type", contentType)
if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
c.Header("Content-Length", contentLength)
}
if updateInfo.AssetName != "" {
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, updateInfo.AssetName))
}
c.Status(http.StatusOK)
_, _ = io.Copy(c.Writer, resp.Body)
}
func (h *InstallHandler) validateInstallToken(c *gin.Context) bool {
token := strings.TrimSpace(c.GetHeader(installTokenHeader))
if token == "" {
c.AbortWithStatus(http.StatusNotFound)
return false
}
store, ok := h.app.GetClientStore().(db.InstallTokenStore)
if !ok {
c.AbortWithStatus(http.StatusNotFound)
return false
}
installToken, err := store.GetInstallToken(token)
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return false
}
if installToken.Used || time.Now().Unix()-installToken.CreatedAt >= installTokenTTL {
c.AbortWithStatus(http.StatusNotFound)
return false
}
return true
}
func applyInstallSecurityHeaders(c *gin.Context) {
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
c.Header("Pragma", "no-cache")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Robots-Tag", "noindex, nofollow, noarchive")
}

View File

@@ -0,0 +1,185 @@
package handler
const shellInstallScript = `#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: bash install.sh -s <server:port> -t <token> -b <web-base-url>
Options:
-s Tunnel server address, for example 10.0.0.2:7000
-t One-time install token generated by the server
-b Web console base URL, for example https://example.com:7500
EOF
}
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "missing required command: $1" >&2
exit 1
fi
}
detect_os() {
case "$(uname -s)" in
Linux) echo "linux" ;;
Darwin) echo "darwin" ;;
*)
echo "unsupported operating system: $(uname -s)" >&2
exit 1
;;
esac
}
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "amd64" ;;
aarch64|arm64) echo "arm64" ;;
i386|i686) echo "386" ;;
armv7l|armv6l|arm) echo "arm" ;;
*)
echo "unsupported architecture: $(uname -m)" >&2
exit 1
;;
esac
}
SERVER_ADDR=""
INSTALL_TOKEN=""
BASE_URL=""
while getopts ":s:t:b:h" opt; do
case "$opt" in
s) SERVER_ADDR="$OPTARG" ;;
t) INSTALL_TOKEN="$OPTARG" ;;
b) BASE_URL="$OPTARG" ;;
h)
usage
exit 0
;;
:)
echo "option -$OPTARG requires a value" >&2
usage
exit 1
;;
\?)
echo "unknown option: -$OPTARG" >&2
usage
exit 1
;;
esac
done
if [[ -z "$SERVER_ADDR" || -z "$INSTALL_TOKEN" || -z "$BASE_URL" ]]; then
usage
exit 1
fi
require_cmd curl
require_cmd tar
require_cmd mktemp
OS_NAME="$(detect_os)"
ARCH_NAME="$(detect_arch)"
BASE_URL="${BASE_URL%/}"
INSTALL_ROOT="${HOME:-$(pwd)}/.gotunnel"
BIN_DIR="$INSTALL_ROOT/bin"
TARGET_BIN="$BIN_DIR/gotunnel-client"
LOG_FILE="$INSTALL_ROOT/client.log"
PID_FILE="$INSTALL_ROOT/client.pid"
TMP_DIR="$(mktemp -d)"
ARCHIVE_PATH="$TMP_DIR/gotunnel-client.tar.gz"
DOWNLOAD_URL="$BASE_URL/install/client?os=$OS_NAME&arch=$ARCH_NAME"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
echo "Downloading GoTunnel client from $DOWNLOAD_URL"
curl -fsSL -H "X-GoTunnel-Install-Token: $INSTALL_TOKEN" "$DOWNLOAD_URL" -o "$ARCHIVE_PATH"
tar -xzf "$ARCHIVE_PATH" -C "$TMP_DIR"
EXTRACTED_BIN="$(find "$TMP_DIR" -type f -name 'gotunnel-client*' ! -name '*.tar.gz' ! -name '*.zip' | head -n 1)"
if [[ -z "$EXTRACTED_BIN" ]]; then
echo "failed to find extracted client binary" >&2
exit 1
fi
cp "$EXTRACTED_BIN" "$TARGET_BIN"
chmod 0755 "$TARGET_BIN"
if [[ -f "$PID_FILE" ]]; then
OLD_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
if [[ -n "$OLD_PID" ]]; then
kill "$OLD_PID" >/dev/null 2>&1 || true
fi
fi
nohup "$TARGET_BIN" -s "$SERVER_ADDR" -t "$INSTALL_TOKEN" >>"$LOG_FILE" 2>&1 &
NEW_PID=$!
echo "$NEW_PID" >"$PID_FILE"
echo "GoTunnel client installed to $TARGET_BIN"
echo "Client started in background with PID $NEW_PID"
echo "Logs: $LOG_FILE"
`
const powerShellInstallScript = `function Get-GoTunnelArch {
switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()) {
'x64' { return 'amd64' }
'arm64' { return 'arm64' }
'x86' { return '386' }
default { throw "Unsupported architecture: $([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)" }
}
}
function Install-GoTunnel {
param(
[Parameter(Mandatory = $true)][string]$Server,
[Parameter(Mandatory = $true)][string]$Token,
[Parameter(Mandatory = $true)][string]$BaseUrl
)
$BaseUrl = $BaseUrl.TrimEnd('/')
$Arch = Get-GoTunnelArch
$InstallRoot = Join-Path $env:LOCALAPPDATA 'GoTunnel'
$ExtractDir = Join-Path $InstallRoot 'extract'
$ArchivePath = Join-Path $InstallRoot 'gotunnel-client.zip'
$TargetPath = Join-Path $InstallRoot 'gotunnel-client.exe'
$DownloadUrl = "$BaseUrl/install/client?os=windows&arch=$Arch"
New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null
Write-Host "Downloading GoTunnel client from $DownloadUrl"
$Headers = @{ 'X-GoTunnel-Install-Token' = $Token }
Invoke-WebRequest -Uri $DownloadUrl -Headers $Headers -OutFile $ArchivePath -MaximumRedirection 5
if (Test-Path $ExtractDir) {
Remove-Item -Path $ExtractDir -Recurse -Force
}
Expand-Archive -Path $ArchivePath -DestinationPath $ExtractDir -Force
$Binary = Get-ChildItem -Path $ExtractDir -Recurse -File |
Where-Object { $_.Name -eq 'gotunnel-client.exe' } |
Select-Object -First 1
if (-not $Binary) {
throw 'Failed to find extracted client binary.'
}
Copy-Item -Path $Binary.FullName -Destination $TargetPath -Force
Get-Process |
Where-Object { $_.Path -eq $TargetPath } |
Stop-Process -Force -ErrorAction SilentlyContinue
Start-Process -FilePath $TargetPath -ArgumentList @('-s', $Server, '-t', $Token) -WindowStyle Hidden
Write-Host "GoTunnel client installed to $TargetPath"
Write-Host 'Client started in background.'
}
`

View File

@@ -13,17 +13,18 @@ type AppInterface interface {
GetConfig() *config.ServerConfig
GetConfigPath() string
SaveConfig() error
GetJSPluginStore() db.JSPluginStore
GetTrafficStore() db.TrafficStore
}
// ServerInterface 服务端接口
type ServerInterface interface {
GetClientStatus(clientID string) (online bool, lastPing, remoteAddr, clientOS, clientArch, clientVersion string)
IsClientOnline(clientID string) bool
GetClientStatus(clientID string) (online bool, lastPing, remoteAddr, clientName, clientOS, clientArch, clientVersion string)
GetAllClientStatus() map[string]struct {
Online bool
LastPing string
RemoteAddr string
Name string
OS string
Arch string
Version string
@@ -33,72 +34,17 @@ type ServerInterface interface {
GetBindPort() int
PushConfigToClient(clientID string) error
DisconnectClient(clientID string) error
GetPluginList() []PluginInfo
EnablePlugin(name string) error
DisablePlugin(name string) error
InstallPluginsToClient(clientID string, plugins []string) error
GetPluginConfigSchema(name string) ([]ConfigField, error)
SyncPluginConfigToClient(clientID string, pluginName string, config map[string]string) error
InstallJSPluginToClient(clientID string, req JSPluginInstallRequest) error
RestartClient(clientID string) error
StartClientPlugin(clientID, pluginID, pluginName, ruleName string) error
StopClientPlugin(clientID, pluginID, pluginName, ruleName string) error
RestartClientPlugin(clientID, pluginID, pluginName, ruleName string) error
UpdateClientPluginConfig(clientID, pluginID, pluginName, ruleName string, config map[string]string, restart bool) error
SendUpdateToClient(clientID, downloadURL string) error
// 日志流
StartClientLogStream(clientID, sessionID string, lines int, follow bool, level string) (<-chan protocol.LogEntry, error)
StopClientLogStream(sessionID string)
// 插件状态查询
GetClientPluginStatus(clientID string) ([]protocol.PluginStatusEntry, error)
// 插件规则管理
StartPluginRule(clientID string, rule protocol.ProxyRule) error
StopPluginRule(clientID string, remotePort int) error
// 端口检查
IsPortAvailable(port int, excludeClientID string) bool
// 插件 API 代理
ProxyPluginAPIRequest(clientID string, req protocol.PluginAPIRequest) (*protocol.PluginAPIResponse, error)
// 系统状态
GetClientSystemStats(clientID string) (*protocol.SystemStatsResponse, error)
}
// ConfigField 配置字段
type ConfigField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"`
Default string `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
Options []string `json:"options,omitempty"`
Description string `json:"description,omitempty"`
}
// RuleSchema 规则表单模式
type RuleSchema struct {
NeedsLocalAddr bool `json:"needs_local_addr"`
ExtraFields []ConfigField `json:"extra_fields,omitempty"`
}
// PluginInfo 插件信息
type PluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
Description string `json:"description"`
Source string `json:"source"`
Icon string `json:"icon,omitempty"`
Enabled bool `json:"enabled"`
RuleSchema *RuleSchema `json:"rule_schema,omitempty"`
}
// JSPluginInstallRequest JS 插件安装请求
type JSPluginInstallRequest struct {
PluginID string `json:"plugin_id"`
PluginName string `json:"plugin_name"`
Source string `json:"source"`
Signature string `json:"signature"`
RuleName string `json:"rule_name"`
RemotePort int `json:"remote_port"`
Config map[string]string `json:"config"`
AutoStart bool `json:"auto_start"`
// 截图
GetClientScreenshot(clientID string, quality int) (*protocol.ScreenshotResponse, error)
// Shell 执行
ExecuteClientShell(clientID, command string, timeout int) (*protocol.ShellExecuteResponse, error)
}

View File

@@ -1,220 +0,0 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/gotunnel/internal/server/db"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
)
// JSPluginHandler JS 插件处理器
type JSPluginHandler struct {
app AppInterface
}
// NewJSPluginHandler 创建 JS 插件处理器
func NewJSPluginHandler(app AppInterface) *JSPluginHandler {
return &JSPluginHandler{app: app}
}
// List 获取 JS 插件列表
// @Summary 获取所有 JS 插件
// @Description 返回所有注册的 JS 插件
// @Tags JS插件
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=[]db.JSPlugin}
// @Router /api/js-plugins [get]
func (h *JSPluginHandler) List(c *gin.Context) {
plugins, err := h.app.GetJSPluginStore().GetAllJSPlugins()
if err != nil {
InternalError(c, err.Error())
return
}
if plugins == nil {
plugins = []db.JSPlugin{}
}
Success(c, plugins)
}
// Create 创建 JS 插件
// @Summary 创建 JS 插件
// @Description 创建新的 JS 插件
// @Tags JS插件
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.JSPluginCreateRequest true "插件信息"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/js-plugins [post]
func (h *JSPluginHandler) Create(c *gin.Context) {
var req dto.JSPluginCreateRequest
if !BindJSON(c, &req) {
return
}
plugin := &db.JSPlugin{
Name: req.Name,
Source: req.Source,
Signature: req.Signature,
Description: req.Description,
Author: req.Author,
Config: req.Config,
AutoStart: req.AutoStart,
Enabled: true,
}
if err := h.app.GetJSPluginStore().SaveJSPlugin(plugin); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Get 获取单个 JS 插件
// @Summary 获取 JS 插件详情
// @Description 获取指定 JS 插件的详细信息
// @Tags JS插件
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Success 200 {object} Response{data=db.JSPlugin}
// @Failure 404 {object} Response
// @Router /api/js-plugin/{name} [get]
func (h *JSPluginHandler) Get(c *gin.Context) {
name := c.Param("name")
plugin, err := h.app.GetJSPluginStore().GetJSPlugin(name)
if err != nil {
NotFound(c, "plugin not found")
return
}
Success(c, plugin)
}
// Update 更新 JS 插件
// @Summary 更新 JS 插件
// @Description 更新指定 JS 插件的信息
// @Tags JS插件
// @Accept json
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Param request body dto.JSPluginUpdateRequest true "更新内容"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/js-plugin/{name} [put]
func (h *JSPluginHandler) Update(c *gin.Context) {
name := c.Param("name")
var req dto.JSPluginUpdateRequest
if !BindJSON(c, &req) {
return
}
plugin := &db.JSPlugin{
Name: name,
Source: req.Source,
Signature: req.Signature,
Description: req.Description,
Author: req.Author,
Config: req.Config,
AutoStart: req.AutoStart,
Enabled: req.Enabled,
}
if err := h.app.GetJSPluginStore().SaveJSPlugin(plugin); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Delete 删除 JS 插件
// @Summary 删除 JS 插件
// @Description 删除指定的 JS 插件
// @Tags JS插件
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Success 200 {object} Response
// @Router /api/js-plugin/{name} [delete]
func (h *JSPluginHandler) Delete(c *gin.Context) {
name := c.Param("name")
if err := h.app.GetJSPluginStore().DeleteJSPlugin(name); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// PushToClient 推送 JS 插件到客户端
// @Summary 推送插件到客户端
// @Description 将 JS 插件推送到指定客户端
// @Tags JS插件
// @Accept json
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Param clientID path string true "客户端ID"
// @Param request body dto.JSPluginPushRequest false "推送配置"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 404 {object} Response
// @Router /api/js-plugin/{name}/push/{clientID} [post]
func (h *JSPluginHandler) PushToClient(c *gin.Context) {
pluginName := c.Param("name")
clientID := c.Param("clientID")
// 解析请求体(可选)
var pushReq dto.JSPluginPushRequest
c.ShouldBindJSON(&pushReq) // 忽略错误,允许空请求体
// 检查客户端是否在线
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
ClientNotOnline(c)
return
}
// 获取插件
plugin, err := h.app.GetJSPluginStore().GetJSPlugin(pluginName)
if err != nil {
NotFound(c, "plugin not found")
return
}
if !plugin.Enabled {
Error(c, 400, CodePluginDisabled, "plugin is disabled")
return
}
// 推送到客户端
req := JSPluginInstallRequest{
PluginName: plugin.Name,
Source: plugin.Source,
Signature: plugin.Signature,
RuleName: plugin.Name,
RemotePort: pushReq.RemotePort,
Config: plugin.Config,
AutoStart: plugin.AutoStart,
}
if err := h.app.GetServer().InstallJSPluginToClient(clientID, req); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{
"status": "ok",
"plugin": pluginName,
"client": clientID,
"remote_port": pushReq.RemotePort,
})
}

View File

@@ -35,8 +35,7 @@ func (h *LogHandler) StreamLogs(c *gin.Context) {
clientID := c.Param("id")
// 检查客户端是否在线
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
if !h.app.GetServer().IsClientOnline(clientID) {
c.JSON(400, gin.H{"code": 400, "message": "client not online"})
return
}

View File

@@ -1,417 +0,0 @@
package handler
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/gotunnel/internal/server/db"
"github.com/gotunnel/internal/server/router/dto"
"github.com/gotunnel/pkg/plugin"
)
// PluginHandler 插件处理器
type PluginHandler struct {
app AppInterface
}
// NewPluginHandler 创建插件处理器
func NewPluginHandler(app AppInterface) *PluginHandler {
return &PluginHandler{app: app}
}
// List 获取插件列表
// @Summary 获取所有插件
// @Description 返回服务端所有注册的插件
// @Tags 插件
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=[]dto.PluginInfo}
// @Router /api/plugins [get]
func (h *PluginHandler) List(c *gin.Context) {
plugins := h.app.GetServer().GetPluginList()
result := make([]dto.PluginInfo, len(plugins))
for i, p := range plugins {
result[i] = dto.PluginInfo{
Name: p.Name,
Version: p.Version,
Type: p.Type,
Description: p.Description,
Source: p.Source,
Icon: p.Icon,
Enabled: p.Enabled,
}
if p.RuleSchema != nil {
result[i].RuleSchema = &dto.RuleSchema{
NeedsLocalAddr: p.RuleSchema.NeedsLocalAddr,
ExtraFields: convertRouterConfigFields(p.RuleSchema.ExtraFields),
}
}
}
Success(c, result)
}
// Enable 启用插件
// @Summary 启用插件
// @Description 启用指定插件
// @Tags 插件
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/plugin/{name}/enable [post]
func (h *PluginHandler) Enable(c *gin.Context) {
name := c.Param("name")
if err := h.app.GetServer().EnablePlugin(name); err != nil {
BadRequest(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Disable 禁用插件
// @Summary 禁用插件
// @Description 禁用指定插件
// @Tags 插件
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/plugin/{name}/disable [post]
func (h *PluginHandler) Disable(c *gin.Context) {
name := c.Param("name")
if err := h.app.GetServer().DisablePlugin(name); err != nil {
BadRequest(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// GetRuleSchemas 获取规则配置模式
// @Summary 获取规则模式
// @Description 返回所有协议类型的配置模式
// @Tags 插件
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=map[string]dto.RuleSchema}
// @Router /api/rule-schemas [get]
func (h *PluginHandler) GetRuleSchemas(c *gin.Context) {
// 获取内置协议模式
schemas := make(map[string]dto.RuleSchema)
for name, schema := range plugin.BuiltinRuleSchemas() {
schemas[name] = dto.RuleSchema{
NeedsLocalAddr: schema.NeedsLocalAddr,
ExtraFields: convertConfigFields(schema.ExtraFields),
}
}
// 添加已注册插件的模式
plugins := h.app.GetServer().GetPluginList()
for _, p := range plugins {
if p.RuleSchema != nil {
schemas[p.Name] = dto.RuleSchema{
NeedsLocalAddr: p.RuleSchema.NeedsLocalAddr,
ExtraFields: convertRouterConfigFields(p.RuleSchema.ExtraFields),
}
}
}
Success(c, schemas)
}
// GetClientConfig 获取客户端插件配置
// @Summary 获取客户端插件配置
// @Description 获取客户端上指定插件的配置
// @Tags 插件
// @Produce json
// @Security Bearer
// @Param clientID path string true "客户端ID"
// @Param pluginName path string true "插件名称"
// @Success 200 {object} Response{data=dto.PluginConfigResponse}
// @Failure 404 {object} Response
// @Router /api/client-plugin/{clientID}/{pluginName}/config [get]
func (h *PluginHandler) GetClientConfig(c *gin.Context) {
clientID := c.Param("clientID")
pluginName := c.Param("pluginName")
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
NotFound(c, "client not found")
return
}
// 查找客户端的插件
var clientPlugin *db.ClientPlugin
for i, p := range client.Plugins {
if p.Name == pluginName {
clientPlugin = &client.Plugins[i]
break
}
}
if clientPlugin == nil {
NotFound(c, "plugin not installed on client")
return
}
var schemaFields []dto.ConfigField
// 优先使用客户端插件保存的 ConfigSchema
if len(clientPlugin.ConfigSchema) > 0 {
for _, f := range clientPlugin.ConfigSchema {
schemaFields = append(schemaFields, dto.ConfigField{
Key: f.Key,
Label: f.Label,
Type: f.Type,
Default: f.Default,
Required: f.Required,
Options: f.Options,
Description: f.Description,
})
}
} else {
// 尝试从内置插件获取配置模式
schema, err := h.app.GetServer().GetPluginConfigSchema(pluginName)
if err != nil {
// 如果内置插件中找不到,尝试从 JS 插件获取
jsPlugin, jsErr := h.app.GetJSPluginStore().GetJSPlugin(pluginName)
if jsErr == nil {
// 使用 JS 插件的 config 作为动态 schema
for key := range jsPlugin.Config {
schemaFields = append(schemaFields, dto.ConfigField{
Key: key,
Label: key,
Type: "string",
})
}
}
} else {
schemaFields = convertRouterConfigFields(schema)
}
}
// 添加 remote_port 作为系统配置字段(始终显示)
schemaFields = append([]dto.ConfigField{{
Key: "remote_port",
Label: "远程端口",
Type: "number",
Description: "服务端监听端口,修改后需重启插件生效",
}}, schemaFields...)
// 添加 Auth 配置字段
schemaFields = append(schemaFields, dto.ConfigField{
Key: "auth_enabled",
Label: "启用认证",
Type: "bool",
Description: "启用 HTTP Basic Auth 保护",
}, dto.ConfigField{
Key: "auth_username",
Label: "认证用户名",
Type: "string",
Description: "HTTP Basic Auth 用户名",
}, dto.ConfigField{
Key: "auth_password",
Label: "认证密码",
Type: "password",
Description: "HTTP Basic Auth 密码",
})
// 构建配置值
config := clientPlugin.Config
if config == nil {
config = make(map[string]string)
}
// 将 remote_port 加入配置
if clientPlugin.RemotePort > 0 {
config["remote_port"] = fmt.Sprintf("%d", clientPlugin.RemotePort)
}
// 将 Auth 配置加入
if clientPlugin.AuthEnabled {
config["auth_enabled"] = "true"
} else {
config["auth_enabled"] = "false"
}
config["auth_username"] = clientPlugin.AuthUsername
config["auth_password"] = clientPlugin.AuthPassword
Success(c, dto.PluginConfigResponse{
PluginName: pluginName,
Schema: schemaFields,
Config: config,
})
}
// UpdateClientConfig 更新客户端插件配置
// @Summary 更新客户端插件配置
// @Description 更新客户端上指定插件的配置
// @Tags 插件
// @Accept json
// @Produce json
// @Security Bearer
// @Param clientID path string true "客户端ID"
// @Param pluginName path string true "插件名称"
// @Param request body dto.PluginConfigRequest true "配置内容"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 404 {object} Response
// @Router /api/client-plugin/{clientID}/{pluginName}/config [put]
func (h *PluginHandler) UpdateClientConfig(c *gin.Context) {
clientID := c.Param("clientID")
pluginName := c.Param("pluginName")
var req dto.PluginConfigRequest
if !BindJSON(c, &req) {
return
}
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
NotFound(c, "client not found")
return
}
// 更新插件配置
found := false
portChanged := false
authChanged := false
var oldPort, newPort int
for i, p := range client.Plugins {
if p.Name == pluginName {
oldPort = client.Plugins[i].RemotePort
// 提取 remote_port 并单独处理
if portStr, ok := req.Config["remote_port"]; ok {
fmt.Sscanf(portStr, "%d", &newPort)
if newPort > 0 && newPort != oldPort {
// 检查新端口是否可用
if !h.app.GetServer().IsPortAvailable(newPort, clientID) {
BadRequest(c, fmt.Sprintf("port %d is already in use", newPort))
return
}
client.Plugins[i].RemotePort = newPort
portChanged = true
}
delete(req.Config, "remote_port") // 不保存到 Config map
}
// 提取 Auth 配置并单独处理
if authEnabledStr, ok := req.Config["auth_enabled"]; ok {
newAuthEnabled := authEnabledStr == "true"
if newAuthEnabled != client.Plugins[i].AuthEnabled {
client.Plugins[i].AuthEnabled = newAuthEnabled
authChanged = true
}
delete(req.Config, "auth_enabled")
}
if authUsername, ok := req.Config["auth_username"]; ok {
if authUsername != client.Plugins[i].AuthUsername {
client.Plugins[i].AuthUsername = authUsername
authChanged = true
}
delete(req.Config, "auth_username")
}
if authPassword, ok := req.Config["auth_password"]; ok {
if authPassword != client.Plugins[i].AuthPassword {
client.Plugins[i].AuthPassword = authPassword
authChanged = true
}
delete(req.Config, "auth_password")
}
client.Plugins[i].Config = req.Config
found = true
break
}
}
if !found {
NotFound(c, "plugin not installed on client")
return
}
// 如果端口变更,同步更新代理规则
if portChanged {
for i, r := range client.Rules {
if r.Name == pluginName && r.PluginManaged {
client.Rules[i].RemotePort = newPort
break
}
}
// 停止旧端口监听器
if oldPort > 0 {
h.app.GetServer().StopPluginRule(clientID, oldPort)
}
}
// 如果 Auth 配置变更,同步更新代理规则
if authChanged {
for i, p := range client.Plugins {
if p.Name == pluginName {
for j, r := range client.Rules {
if r.Name == pluginName && r.PluginManaged {
client.Rules[j].AuthEnabled = client.Plugins[i].AuthEnabled
client.Rules[j].AuthUsername = client.Plugins[i].AuthUsername
client.Rules[j].AuthPassword = client.Plugins[i].AuthPassword
break
}
}
break
}
}
}
// 保存到数据库
if err := h.app.GetClientStore().UpdateClient(client); err != nil {
InternalError(c, err.Error())
return
}
// 如果客户端在线,同步配置
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
if online {
if err := h.app.GetServer().SyncPluginConfigToClient(clientID, pluginName, req.Config); err != nil {
PartialSuccess(c, gin.H{"status": "partial", "port_changed": portChanged}, "config saved but sync failed: "+err.Error())
return
}
}
Success(c, gin.H{"status": "ok", "port_changed": portChanged})
}
// convertConfigFields 转换插件配置字段到 DTO
func convertConfigFields(fields []plugin.ConfigField) []dto.ConfigField {
result := make([]dto.ConfigField, len(fields))
for i, f := range fields {
result[i] = dto.ConfigField{
Key: f.Key,
Label: f.Label,
Type: string(f.Type),
Default: f.Default,
Required: f.Required,
Options: f.Options,
Description: f.Description,
}
}
return result
}
// convertRouterConfigFields 转换 ConfigField 到 dto.ConfigField
func convertRouterConfigFields(fields []ConfigField) []dto.ConfigField {
result := make([]dto.ConfigField, len(fields))
for i, f := range fields {
result[i] = dto.ConfigField{
Key: f.Key,
Label: f.Label,
Type: f.Type,
Default: f.Default,
Required: f.Required,
Options: f.Options,
Description: f.Description,
}
}
return result
}

View File

@@ -1,140 +0,0 @@
package handler
import (
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gotunnel/pkg/protocol"
)
// PluginAPIHandler 插件 API 代理处理器
type PluginAPIHandler struct {
app AppInterface
}
// NewPluginAPIHandler 创建插件 API 代理处理器
func NewPluginAPIHandler(app AppInterface) *PluginAPIHandler {
return &PluginAPIHandler{app: app}
}
// ProxyRequest 代理请求到客户端插件
// @Summary 代理插件 API 请求
// @Description 将请求代理到客户端的 JS 插件处理
// @Tags 插件 API
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "客户端 ID"
// @Param pluginID path string true "插件实例 ID"
// @Param route path string true "插件路由"
// @Success 200 {object} object
// @Failure 404 {object} Response
// @Failure 502 {object} Response
// @Router /api/client/{id}/plugin-api/{pluginID}/{route} [get]
func (h *PluginAPIHandler) ProxyRequest(c *gin.Context) {
clientID := c.Param("id")
pluginID := c.Param("pluginID")
route := c.Param("route")
// 确保路由以 / 开头
if !strings.HasPrefix(route, "/") {
route = "/" + route
}
// 检查客户端是否在线
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
ClientNotOnline(c)
return
}
// 读取请求体
var body string
if c.Request.Body != nil {
bodyBytes, _ := io.ReadAll(c.Request.Body)
body = string(bodyBytes)
}
// 构建请求头
headers := make(map[string]string)
for key, values := range c.Request.Header {
if len(values) > 0 {
headers[key] = values[0]
}
}
// 构建 API 请求
apiReq := protocol.PluginAPIRequest{
PluginID: pluginID,
Method: c.Request.Method,
Path: route,
Query: c.Request.URL.RawQuery,
Headers: headers,
Body: body,
}
// 发送请求到客户端
resp, err := h.app.GetServer().ProxyPluginAPIRequest(clientID, apiReq)
if err != nil {
BadGateway(c, "Plugin request failed: "+err.Error())
return
}
// 检查错误
if resp.Error != "" {
c.JSON(http.StatusBadGateway, gin.H{
"code": 502,
"message": resp.Error,
})
return
}
// 设置响应头
for key, value := range resp.Headers {
c.Header(key, value)
}
// 返回响应
c.String(resp.Status, resp.Body)
}
// ProxyPluginAPIRequest 接口方法声明 - 添加到 ServerInterface
type PluginAPIProxyInterface interface {
ProxyPluginAPIRequest(clientID string, req protocol.PluginAPIRequest) (*protocol.PluginAPIResponse, error)
}
// AuthConfig 认证配置
type AuthConfig struct {
Type string `json:"type"` // none, basic, token
Username string `json:"username"` // Basic Auth 用户名
Password string `json:"password"` // Basic Auth 密码
Token string `json:"token"` // Token 认证
}
// BasicAuthMiddleware 创建 Basic Auth 中间件
func BasicAuthMiddleware(username, password string) gin.HandlerFunc {
return func(c *gin.Context) {
user, pass, ok := c.Request.BasicAuth()
if !ok || user != username || pass != password {
c.Header("WWW-Authenticate", `Basic realm="Plugin"`)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "Unauthorized",
})
return
}
c.Next()
}
}
// WithTimeout 带超时的请求处理
func WithTimeout(timeout time.Duration, handler gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
// 设置请求超时
c.Request = c.Request.WithContext(c.Request.Context())
handler(c)
}
}

View File

@@ -1,293 +0,0 @@
package handler
import (
"fmt"
"io"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gotunnel/internal/server/db"
"github.com/gotunnel/internal/server/router/dto"
"github.com/gotunnel/pkg/protocol"
)
// StoreHandler 插件商店处理器
type StoreHandler struct {
app AppInterface
}
// NewStoreHandler 创建插件商店处理器
func NewStoreHandler(app AppInterface) *StoreHandler {
return &StoreHandler{app: app}
}
// ListPlugins 获取商店插件列表
// @Summary 获取商店插件
// @Description 从远程插件商店获取可用插件列表
// @Tags 插件商店
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=object{plugins=[]dto.StorePluginInfo}}
// @Failure 502 {object} Response
// @Router /api/store/plugins [get]
func (h *StoreHandler) ListPlugins(c *gin.Context) {
cfg := h.app.GetConfig()
storeURL := cfg.Server.PluginStore.GetPluginStoreURL()
// 从远程 URL 获取插件列表
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(storeURL)
if err != nil {
BadGateway(c, "Failed to fetch store: "+err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
BadGateway(c, "Store returned error")
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
InternalError(c, "Failed to read response")
return
}
// 直接返回原始 JSON已经是数组格式
c.Header("Content-Type", "application/json")
c.Writer.Write([]byte(`{"code":0,"data":{"plugins":`))
c.Writer.Write(body)
c.Writer.Write([]byte(`}}`))
}
// Install 从商店安装插件到客户端
// @Summary 安装商店插件
// @Description 从插件商店下载并安装插件到指定客户端
// @Tags 插件商店
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.StoreInstallRequest true "安装请求"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 502 {object} Response
// @Router /api/store/install [post]
func (h *StoreHandler) Install(c *gin.Context) {
var req dto.StoreInstallRequest
if !BindJSON(c, &req) {
return
}
// 检查客户端是否在线
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(req.ClientID)
if !online {
ClientNotOnline(c)
return
}
// 下载插件
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(req.DownloadURL)
if err != nil {
BadGateway(c, "Failed to download plugin: "+err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
BadGateway(c, "Plugin download failed with status: "+resp.Status)
return
}
source, err := io.ReadAll(resp.Body)
if err != nil {
InternalError(c, "Failed to read plugin: "+err.Error())
return
}
// 下载签名文件
sigResp, err := client.Get(req.SignatureURL)
if err != nil {
BadGateway(c, "Failed to download signature: "+err.Error())
return
}
defer sigResp.Body.Close()
if sigResp.StatusCode != http.StatusOK {
BadGateway(c, "Signature download failed with status: "+sigResp.Status)
return
}
signature, err := io.ReadAll(sigResp.Body)
if err != nil {
InternalError(c, "Failed to read signature: "+err.Error())
return
}
// 检查插件是否已存在,决定使用已有 ID 还是生成新 ID
pluginID := ""
dbClient, err := h.app.GetClientStore().GetClient(req.ClientID)
if err == nil {
for _, p := range dbClient.Plugins {
if p.Name == req.PluginName && p.ID != "" {
pluginID = p.ID
break
}
}
}
if pluginID == "" {
pluginID = uuid.New().String()
}
// 安装到客户端
installReq := JSPluginInstallRequest{
PluginID: pluginID,
PluginName: req.PluginName,
Source: string(source),
Signature: string(signature),
RuleName: req.PluginName,
RemotePort: req.RemotePort,
AutoStart: true,
}
if err := h.app.GetServer().InstallJSPluginToClient(req.ClientID, installReq); err != nil {
InternalError(c, "Failed to install plugin: "+err.Error())
return
}
// 将插件保存到 JSPluginStore用于客户端重连时恢复
jsPlugin := &db.JSPlugin{
Name: req.PluginName,
Source: string(source),
Signature: string(signature),
AutoStart: true,
Enabled: true,
}
// 尝试保存,忽略错误(可能已存在)
h.app.GetJSPluginStore().SaveJSPlugin(jsPlugin)
// 将插件信息保存到客户端记录
// 重新获取 dbClient可能已被修改
dbClient, err = h.app.GetClientStore().GetClient(req.ClientID)
if err == nil {
// 检查插件是否已存在(通过名称匹配)
pluginExists := false
for i, p := range dbClient.Plugins {
if p.Name == req.PluginName {
dbClient.Plugins[i].Enabled = true
dbClient.Plugins[i].RemotePort = req.RemotePort
dbClient.Plugins[i].AuthEnabled = req.AuthEnabled
dbClient.Plugins[i].AuthUsername = req.AuthUsername
dbClient.Plugins[i].AuthPassword = req.AuthPassword
// 确保有 ID
if dbClient.Plugins[i].ID == "" {
dbClient.Plugins[i].ID = pluginID
}
pluginExists = true
break
}
}
if !pluginExists {
version := req.Version
if version == "" {
version = "1.0.0"
}
// 转换 ConfigSchema
var configSchema []db.ConfigField
for _, f := range req.ConfigSchema {
configSchema = append(configSchema, db.ConfigField{
Key: f.Key,
Label: f.Label,
Type: f.Type,
Default: f.Default,
Required: f.Required,
Options: f.Options,
Description: f.Description,
})
}
dbClient.Plugins = append(dbClient.Plugins, db.ClientPlugin{
ID: pluginID,
Name: req.PluginName,
Version: version,
Enabled: true,
RemotePort: req.RemotePort,
ConfigSchema: configSchema,
AuthEnabled: req.AuthEnabled,
AuthUsername: req.AuthUsername,
AuthPassword: req.AuthPassword,
})
}
// 自动创建代理规则(如果指定了端口)
if req.RemotePort > 0 {
// 检查端口是否可用
if !h.app.GetServer().IsPortAvailable(req.RemotePort, req.ClientID) {
InternalError(c, fmt.Sprintf("port %d is already in use", req.RemotePort))
return
}
ruleExists := false
for i, r := range dbClient.Rules {
if r.Name == req.PluginName {
// 更新现有规则
dbClient.Rules[i].Type = req.PluginName
dbClient.Rules[i].RemotePort = req.RemotePort
dbClient.Rules[i].Enabled = boolPtr(true)
dbClient.Rules[i].PluginID = pluginID
dbClient.Rules[i].AuthEnabled = req.AuthEnabled
dbClient.Rules[i].AuthUsername = req.AuthUsername
dbClient.Rules[i].AuthPassword = req.AuthPassword
dbClient.Rules[i].PluginManaged = true
ruleExists = true
break
}
}
if !ruleExists {
// 创建新规则
dbClient.Rules = append(dbClient.Rules, protocol.ProxyRule{
Name: req.PluginName,
Type: req.PluginName,
RemotePort: req.RemotePort,
Enabled: boolPtr(true),
PluginID: pluginID,
AuthEnabled: req.AuthEnabled,
AuthUsername: req.AuthUsername,
AuthPassword: req.AuthPassword,
PluginManaged: true,
})
}
}
h.app.GetClientStore().UpdateClient(dbClient)
}
// 启动服务端监听器(让外部用户可以通过 RemotePort 访问插件)
if req.RemotePort > 0 {
pluginRule := protocol.ProxyRule{
Name: req.PluginName,
Type: req.PluginName, // 使用插件名作为类型,让 isClientPlugin 识别
RemotePort: req.RemotePort,
Enabled: boolPtr(true),
PluginID: pluginID,
AuthEnabled: req.AuthEnabled,
AuthUsername: req.AuthUsername,
AuthPassword: req.AuthPassword,
}
// 启动监听器(忽略错误,可能端口已被占用)
h.app.GetServer().StartPluginRule(req.ClientID, pluginRule)
}
Success(c, gin.H{
"status": "ok",
"plugin": req.PluginName,
"plugin_id": pluginID,
"client": req.ClientID,
})
}
// boolPtr 返回 bool 值的指针
func boolPtr(b bool) *bool {
return &b
}

View File

@@ -48,6 +48,11 @@ func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth,
engine.POST("/api/auth/login", authHandler.Login)
engine.GET("/api/auth/check", authHandler.Check)
installHandler := handler.NewInstallHandler(app)
engine.GET("/install.sh", installHandler.ServeShellScript)
engine.GET("/install.ps1", installHandler.ServePowerShellScript)
engine.GET("/install/client", installHandler.DownloadClient)
// API 路由 (需要 JWT)
api := engine.Group("/api")
api.Use(middleware.JWTAuth(jwtAuth))
@@ -67,9 +72,9 @@ func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth,
api.POST("/client/:id/push", clientHandler.PushConfig)
api.POST("/client/:id/disconnect", clientHandler.Disconnect)
api.POST("/client/:id/restart", clientHandler.Restart)
api.POST("/client/:id/install-plugins", clientHandler.InstallPlugins)
api.POST("/client/:id/plugin/:pluginID/:action", clientHandler.PluginAction)
api.GET("/client/:id/system-stats", clientHandler.GetSystemStats)
api.GET("/client/:id/screenshot", clientHandler.GetScreenshot)
api.POST("/client/:id/shell", clientHandler.ExecuteShell)
// 配置管理
configHandler := handler.NewConfigHandler(app)
@@ -77,29 +82,6 @@ func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth,
api.PUT("/config", configHandler.Update)
api.POST("/config/reload", configHandler.Reload)
// 插件管理
pluginHandler := handler.NewPluginHandler(app)
api.GET("/plugins", pluginHandler.List)
api.POST("/plugin/:name/enable", pluginHandler.Enable)
api.POST("/plugin/:name/disable", pluginHandler.Disable)
api.GET("/rule-schemas", pluginHandler.GetRuleSchemas)
api.GET("/client-plugin/:clientID/:pluginName/config", pluginHandler.GetClientConfig)
api.PUT("/client-plugin/:clientID/:pluginName/config", pluginHandler.UpdateClientConfig)
// JS 插件管理
jsPluginHandler := handler.NewJSPluginHandler(app)
api.GET("/js-plugins", jsPluginHandler.List)
api.POST("/js-plugins", jsPluginHandler.Create)
api.GET("/js-plugin/:name", jsPluginHandler.Get)
api.PUT("/js-plugin/:name", jsPluginHandler.Update)
api.DELETE("/js-plugin/:name", jsPluginHandler.Delete)
api.POST("/js-plugin/:name/push/:clientID", jsPluginHandler.PushToClient)
// 插件商店
storeHandler := handler.NewStoreHandler(app)
api.GET("/store/plugins", storeHandler.ListPlugins)
api.POST("/store/install", storeHandler.Install)
// 更新管理
updateHandler := handler.NewUpdateHandler(app)
api.GET("/update/check/server", updateHandler.CheckServer)
@@ -116,9 +98,8 @@ func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth,
api.GET("/traffic/stats", trafficHandler.GetStats)
api.GET("/traffic/hourly", trafficHandler.GetHourly)
// 插件 API 代理 (通过 Web API 访问客户端插件)
pluginAPIHandler := handler.NewPluginAPIHandler(app)
api.Any("/client/:id/plugin-api/:pluginID/*route", pluginAPIHandler.ProxyRequest)
// 安装命令生成
api.POST("/install/generate", installHandler.GenerateInstallCommand)
}
}
@@ -198,10 +179,6 @@ func isStaticAsset(path string) bool {
// Re-export types from handler package for backward compatibility
type (
ServerInterface = handler.ServerInterface
AppInterface = handler.AppInterface
ConfigField = handler.ConfigField
RuleSchema = handler.RuleSchema
PluginInfo = handler.PluginInfo
JSPluginInstallRequest = handler.JSPluginInstallRequest
ServerInterface = handler.ServerInterface
AppInterface = handler.AppInterface
)

File diff suppressed because it is too large Load Diff

View File

@@ -142,5 +142,5 @@ func (s *Server) handleWebsocketProxyConn(cs *ClientSession, conn net.Conn, rule
return
}
relay.Relay(conn, stream)
relay.RelayWithStats(conn, stream, s.recordTraffic)
}

View File

@@ -0,0 +1,144 @@
package gotunnelmobile
import (
"context"
"strings"
"sync"
"github.com/gotunnel/internal/client/tunnel"
"github.com/gotunnel/pkg/crypto"
)
// Service exposes a gomobile-friendly wrapper around the Go tunnel client.
type Service struct {
mu sync.Mutex
server string
token string
dataDir string
clientName string
clientID string
disableTLS bool
client *tunnel.Client
cancel context.CancelFunc
running bool
status string
lastError string
}
// NewService creates a mobile client service wrapper.
func NewService() *Service {
return &Service{status: "stopped"}
}
// Configure stores the parameters used by Start.
func (s *Service) Configure(server, token, dataDir, clientName, clientID string, disableTLS bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.server = strings.TrimSpace(server)
s.token = strings.TrimSpace(token)
s.dataDir = strings.TrimSpace(dataDir)
s.clientName = strings.TrimSpace(clientName)
s.clientID = strings.TrimSpace(clientID)
s.disableTLS = disableTLS
}
// Start launches the tunnel loop in the background.
func (s *Service) Start() string {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return ""
}
if s.server == "" || s.token == "" {
s.mu.Unlock()
return "server and token are required"
}
features := tunnel.MobilePlatformFeatures()
client := tunnel.NewClientWithOptions(s.server, s.token, tunnel.ClientOptions{
DataDir: s.dataDir,
ClientID: s.clientID,
ClientName: s.clientName,
Features: &features,
})
if !s.disableTLS {
client.TLSEnabled = true
client.TLSConfig = crypto.ClientTLSConfig()
}
ctx, cancel := context.WithCancel(context.Background())
s.client = client
s.cancel = cancel
s.running = true
s.status = "running"
s.lastError = ""
s.mu.Unlock()
go func() {
err := client.RunContext(ctx)
s.mu.Lock()
defer s.mu.Unlock()
s.running = false
s.cancel = nil
s.client = nil
if err != nil {
s.status = "error"
s.lastError = err.Error()
return
}
if s.status != "stopped" {
s.status = "stopped"
}
}()
return ""
}
// Stop cancels the running tunnel loop.
func (s *Service) Stop() string {
s.mu.Lock()
cancel := s.cancel
s.cancel = nil
s.running = false
s.status = "stopped"
s.mu.Unlock()
if cancel != nil {
cancel()
}
return ""
}
// Restart restarts the service with the stored configuration.
func (s *Service) Restart() string {
s.Stop()
return s.Start()
}
// IsRunning reports whether the tunnel loop is active.
func (s *Service) IsRunning() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
}
// Status returns a coarse-grained runtime status.
func (s *Service) Status() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.status
}
// LastError returns the last background error string, if any.
func (s *Service) LastError() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastError
}

View File

@@ -1,154 +0,0 @@
package audit
import (
"encoding/json"
"log"
"os"
"path/filepath"
"sync"
"time"
)
// EventType 审计事件类型
type EventType string
const (
EventPluginInstall EventType = "plugin_install"
EventPluginUninstall EventType = "plugin_uninstall"
EventPluginStart EventType = "plugin_start"
EventPluginStop EventType = "plugin_stop"
EventPluginVerify EventType = "plugin_verify"
EventPluginReject EventType = "plugin_reject"
EventConfigChange EventType = "config_change"
)
// Event 审计事件
type Event struct {
Timestamp time.Time `json:"timestamp"`
Type EventType `json:"type"`
PluginName string `json:"plugin_name,omitempty"`
Version string `json:"version,omitempty"`
ClientID string `json:"client_id,omitempty"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Details map[string]string `json:"details,omitempty"`
}
// Logger 审计日志记录器
type Logger struct {
path string
file *os.File
mu sync.Mutex
enabled bool
}
var (
defaultLogger *Logger
loggerOnce sync.Once
)
// NewLogger 创建审计日志记录器
func NewLogger(dataDir string) (*Logger, error) {
path := filepath.Join(dataDir, "audit.log")
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return nil, err
}
return &Logger{path: path, file: file, enabled: true}, nil
}
// InitDefault 初始化默认日志记录器
func InitDefault(dataDir string) error {
var err error
loggerOnce.Do(func() {
defaultLogger, err = NewLogger(dataDir)
})
return err
}
// Log 记录审计事件
func (l *Logger) Log(event Event) {
if l == nil || !l.enabled {
return
}
event.Timestamp = time.Now()
l.mu.Lock()
defer l.mu.Unlock()
data, err := json.Marshal(event)
if err != nil {
log.Printf("[Audit] Marshal error: %v", err)
return
}
if _, err := l.file.Write(append(data, '\n')); err != nil {
log.Printf("[Audit] Write error: %v", err)
}
}
// Close 关闭日志文件
func (l *Logger) Close() error {
if l == nil || l.file == nil {
return nil
}
return l.file.Close()
}
// LogEvent 使用默认记录器记录事件
func LogEvent(event Event) {
if defaultLogger != nil {
defaultLogger.Log(event)
}
}
// LogPluginInstall 记录插件安装事件
func LogPluginInstall(pluginName, version, clientID string, success bool, msg string) {
LogEvent(Event{
Type: EventPluginInstall,
PluginName: pluginName,
Version: version,
ClientID: clientID,
Success: success,
Message: msg,
})
}
// LogPluginVerify 记录插件验证事件
func LogPluginVerify(pluginName, version string, success bool, msg string) {
LogEvent(Event{
Type: EventPluginVerify,
PluginName: pluginName,
Version: version,
Success: success,
Message: msg,
})
}
// LogPluginReject 记录插件拒绝事件
func LogPluginReject(pluginName, version, reason string) {
LogEvent(Event{
Type: EventPluginReject,
PluginName: pluginName,
Version: version,
Success: false,
Message: reason,
})
}
// LogWithDetails 记录带详情的事件
func LogWithDetails(eventType EventType, pluginName string, success bool, msg string, details map[string]string) {
LogEvent(Event{
Type: eventType,
PluginName: pluginName,
Success: success,
Message: msg,
Details: details,
})
}

View File

@@ -1,134 +0,0 @@
package plugin
import (
"context"
"fmt"
"sync"
)
// Registry 管理可用的 plugins (仅客户端插件)
type Registry struct {
clientPlugins map[string]ClientPlugin // 客户端插件
enabled map[string]bool // 启用状态
mu sync.RWMutex
}
// NewRegistry 创建 plugin 注册表
func NewRegistry() *Registry {
return &Registry{
clientPlugins: make(map[string]ClientPlugin),
enabled: make(map[string]bool),
}
}
// RegisterClient 注册客户端插件
func (r *Registry) RegisterClient(handler ClientPlugin) error {
r.mu.Lock()
defer r.mu.Unlock()
meta := handler.Metadata()
if meta.Name == "" {
return fmt.Errorf("plugin name cannot be empty")
}
if _, exists := r.clientPlugins[meta.Name]; exists {
return fmt.Errorf("client plugin %s already registered", meta.Name)
}
r.clientPlugins[meta.Name] = handler
r.enabled[meta.Name] = true
return nil
}
// GetClient 返回客户端插件
func (r *Registry) GetClient(name string) (ClientPlugin, error) {
r.mu.RLock()
defer r.mu.RUnlock()
if handler, ok := r.clientPlugins[name]; ok {
if !r.enabled[name] {
return nil, fmt.Errorf("client plugin %s is disabled", name)
}
return handler, nil
}
return nil, fmt.Errorf("client plugin %s not found", name)
}
// List 返回所有可用的 plugins
func (r *Registry) List() []Info {
r.mu.RLock()
defer r.mu.RUnlock()
var plugins []Info
for name, handler := range r.clientPlugins {
plugins = append(plugins, Info{
Metadata: handler.Metadata(),
Loaded: true,
Enabled: r.enabled[name],
})
}
return plugins
}
// Has 检查 plugin 是否存在
func (r *Registry) Has(name string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, ok := r.clientPlugins[name]
return ok
}
// Close 关闭所有 plugins
func (r *Registry) Close(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
var lastErr error
for name, handler := range r.clientPlugins {
if err := handler.Stop(); err != nil {
lastErr = fmt.Errorf("failed to stop client plugin %s: %w", name, err)
}
}
return lastErr
}
// Enable 启用插件
func (r *Registry) Enable(name string) error {
r.mu.Lock()
defer r.mu.Unlock()
if !r.has(name) {
return fmt.Errorf("plugin %s not found", name)
}
r.enabled[name] = true
return nil
}
// Disable 禁用插件
func (r *Registry) Disable(name string) error {
r.mu.Lock()
defer r.mu.Unlock()
if !r.has(name) {
return fmt.Errorf("plugin %s not found", name)
}
r.enabled[name] = false
return nil
}
// has 内部检查(无锁)
func (r *Registry) has(name string) bool {
_, ok := r.clientPlugins[name]
return ok
}
// IsEnabled 检查插件是否启用
func (r *Registry) IsEnabled(name string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
return r.enabled[name]
}

View File

@@ -1,109 +0,0 @@
package plugin
// 内置协议类型配置模式
// BuiltinRuleSchemas 返回所有内置协议类型的配置模式
func BuiltinRuleSchemas() map[string]RuleSchema {
return map[string]RuleSchema{
"tcp": {
NeedsLocalAddr: true,
ExtraFields: nil,
},
"udp": {
NeedsLocalAddr: true,
ExtraFields: nil,
},
"http": {
NeedsLocalAddr: false,
ExtraFields: []ConfigField{
{
Key: "auth_enabled",
Label: "启用认证",
Type: ConfigFieldBool,
Default: "false",
Description: "是否启用 HTTP Basic 认证",
},
{
Key: "username",
Label: "用户名",
Type: ConfigFieldString,
Description: "HTTP 代理认证用户名",
},
{
Key: "password",
Label: "密码",
Type: ConfigFieldPassword,
Description: "HTTP 代理认证密码",
},
},
},
"https": {
NeedsLocalAddr: false,
ExtraFields: []ConfigField{
{
Key: "auth_enabled",
Label: "启用认证",
Type: ConfigFieldBool,
Default: "false",
Description: "是否启用 HTTPS 代理认证",
},
{
Key: "username",
Label: "用户名",
Type: ConfigFieldString,
Description: "HTTPS 代理认证用户名",
},
{
Key: "password",
Label: "密码",
Type: ConfigFieldPassword,
Description: "HTTPS 代理认证密码",
},
},
},
"socks5": {
NeedsLocalAddr: false,
ExtraFields: []ConfigField{
{
Key: "auth_enabled",
Label: "启用认证",
Type: ConfigFieldBool,
Default: "false",
Description: "是否启用 SOCKS5 用户名/密码认证",
},
{
Key: "username",
Label: "用户名",
Type: ConfigFieldString,
Description: "SOCKS5 认证用户名",
},
{
Key: "password",
Label: "密码",
Type: ConfigFieldPassword,
Description: "SOCKS5 认证密码",
},
},
},
}
}
// GetRuleSchema 获取指定协议类型的配置模式
func GetRuleSchema(proxyType string) *RuleSchema {
schemas := BuiltinRuleSchemas()
if schema, ok := schemas[proxyType]; ok {
return &schema
}
return nil
}
// IsBuiltinType 检查是否为内置协议类型
func IsBuiltinType(proxyType string) bool {
builtinTypes := []string{"tcp", "udp", "http", "https"}
for _, t := range builtinTypes {
if t == proxyType {
return true
}
}
return false
}

View File

@@ -1,913 +0,0 @@
package script
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/dop251/goja"
"github.com/gotunnel/pkg/plugin"
)
// JSPlugin JavaScript 脚本插件
type JSPlugin struct {
name string
source string
vm *goja.Runtime
metadata plugin.Metadata
config map[string]string
sandbox *Sandbox
running bool
mu sync.Mutex
eventListeners map[string][]func(goja.Value)
storagePath string
apiHandlers map[string]map[string]goja.Callable // method -> path -> handler
}
// NewJSPlugin 从 JS 源码创建插件
func NewJSPlugin(name, source string) (*JSPlugin, error) {
p := &JSPlugin{
name: name,
source: source,
vm: goja.New(),
sandbox: DefaultSandbox(),
eventListeners: make(map[string][]func(goja.Value)),
storagePath: filepath.Join("plugin_data", name+".json"),
apiHandlers: make(map[string]map[string]goja.Callable),
}
// 确保存储目录存在
os.MkdirAll("plugin_data", 0755)
if err := p.init(); err != nil {
return nil, err
}
return p, nil
}
// SetSandbox 设置沙箱配置
func (p *JSPlugin) SetSandbox(sandbox *Sandbox) {
p.sandbox = sandbox
}
// init 初始化 JS 运行时
func (p *JSPlugin) init() error {
// 设置栈深度限制(防止递归攻击)
if p.sandbox.MaxStackDepth > 0 {
p.vm.SetMaxCallStackSize(p.sandbox.MaxStackDepth)
}
// 注入基础 API
p.vm.Set("log", p.jsLog)
// Config API (兼容旧的 config() 调用,同时支持 config.get/getAll)
p.vm.Set("config", p.jsGetConfig)
if configObj := p.vm.Get("config"); configObj != nil {
obj := configObj.ToObject(p.vm)
obj.Set("get", p.jsGetConfig)
obj.Set("getAll", p.jsGetAllConfig)
}
// 注入增强 API
p.vm.Set("logger", p.createLoggerAPI())
p.vm.Set("storage", p.createStorageAPI())
p.vm.Set("event", p.createEventAPI())
p.vm.Set("request", p.createRequestAPI())
p.vm.Set("notify", p.createNotifyAPI())
// 注入文件 API
p.vm.Set("fs", p.createFsAPI())
// 注入 HTTP API
p.vm.Set("http", p.createHttpAPI())
// 注入路由 API
p.vm.Set("api", p.createRouteAPI())
// 执行脚本
_, err := p.vm.RunString(p.source)
if err != nil {
return fmt.Errorf("run script: %w", err)
}
// 获取元数据
if err := p.loadMetadata(); err != nil {
return err
}
return nil
}
// loadMetadata 从 JS 获取元数据
func (p *JSPlugin) loadMetadata() error {
fn, ok := goja.AssertFunction(p.vm.Get("metadata"))
if !ok {
// 使用默认元数据
p.metadata = plugin.Metadata{
Name: p.name,
Type: plugin.PluginTypeApp,
Source: plugin.PluginSourceScript,
RunAt: plugin.SideClient,
}
return nil
}
result, err := fn(goja.Undefined())
if err != nil {
return err
}
obj := result.ToObject(p.vm)
p.metadata = plugin.Metadata{
Name: getString(obj, "name", p.name),
Version: getString(obj, "version", "1.0.0"),
Type: plugin.PluginType(getString(obj, "type", "app")),
Source: plugin.PluginSourceScript,
RunAt: plugin.Side(getString(obj, "run_at", "client")),
Description: getString(obj, "description", ""),
Author: getString(obj, "author", ""),
}
return nil
}
// Metadata 返回插件元数据
func (p *JSPlugin) Metadata() plugin.Metadata {
return p.metadata
}
// Init 初始化插件配置
func (p *JSPlugin) Init(config map[string]string) error {
p.config = config
// 根据 root_path 配置设置沙箱允许的路径
if rootPath := config["root_path"]; rootPath != "" {
absPath, err := filepath.Abs(rootPath)
if err == nil {
p.sandbox.AllowedPaths = append(p.sandbox.AllowedPaths, absPath)
p.sandbox.WritablePaths = append(p.sandbox.WritablePaths, absPath)
}
} else {
// 如果没有配置 root_path默认允许访问当前目录
cwd, err := os.Getwd()
if err == nil {
p.sandbox.AllowedPaths = append(p.sandbox.AllowedPaths, cwd)
p.sandbox.WritablePaths = append(p.sandbox.WritablePaths, cwd)
}
}
return nil
}
// Start 启动插件
func (p *JSPlugin) Start() (string, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.running {
return "", nil
}
fn, ok := goja.AssertFunction(p.vm.Get("start"))
if ok {
_, err := fn(goja.Undefined())
if err != nil {
return "", err
}
}
p.running = true
return "script-plugin", nil
}
// HandleConn 处理连接
func (p *JSPlugin) HandleConn(conn net.Conn) error {
defer conn.Close()
// goja Runtime 不是线程安全的,需要加锁
p.mu.Lock()
defer p.mu.Unlock()
// 创建连接包装器
jsConn := newJSConn(conn)
fn, ok := goja.AssertFunction(p.vm.Get("handleConn"))
if !ok {
return fmt.Errorf("handleConn not defined")
}
_, err := fn(goja.Undefined(), p.vm.ToValue(jsConn))
return err
}
// Stop 停止插件
func (p *JSPlugin) Stop() error {
p.mu.Lock()
defer p.mu.Unlock()
if !p.running {
return nil
}
fn, ok := goja.AssertFunction(p.vm.Get("stop"))
if ok {
fn(goja.Undefined())
}
p.running = false
return nil
}
// jsLog JS 日志函数
func (p *JSPlugin) jsLog(msg string) {
fmt.Printf("[JS:%s] %s\n", p.name, msg)
}
// jsGetConfig 获取配置
func (p *JSPlugin) jsGetConfig(key string) string {
if p.config == nil {
return ""
}
return p.config[key]
}
// getString 从 JS 对象获取字符串
func getString(obj *goja.Object, key, def string) string {
v := obj.Get(key)
if v == nil || goja.IsUndefined(v) {
return def
}
return v.String()
}
// jsConn JS 连接包装器
type jsConn struct {
conn net.Conn
}
func newJSConn(conn net.Conn) *jsConn {
return &jsConn{conn: conn}
}
func (c *jsConn) Read(size int) []byte {
buf := make([]byte, size)
n, err := c.conn.Read(buf)
if err != nil {
return nil
}
return buf[:n]
}
func (c *jsConn) Write(data []byte) int {
n, _ := c.conn.Write(data)
return n
}
func (c *jsConn) Close() {
c.conn.Close()
}
// =============================================================================
// 文件系统 API
// =============================================================================
// createFsAPI 创建文件系统 API
func (p *JSPlugin) createFsAPI() map[string]interface{} {
return map[string]interface{}{
"readFile": p.fsReadFile,
"writeFile": p.fsWriteFile,
"readDir": p.fsReadDir,
"stat": p.fsStat,
"exists": p.fsExists,
"mkdir": p.fsMkdir,
"remove": p.fsRemove,
}
}
func (p *JSPlugin) fsReadFile(path string) map[string]interface{} {
if err := p.sandbox.ValidateReadPath(path); err != nil {
return map[string]interface{}{"error": err.Error(), "data": ""}
}
info, err := os.Stat(path)
if err != nil {
return map[string]interface{}{"error": err.Error(), "data": ""}
}
if info.Size() > p.sandbox.MaxReadSize {
return map[string]interface{}{"error": "file too large", "data": ""}
}
data, err := os.ReadFile(path)
if err != nil {
return map[string]interface{}{"error": err.Error(), "data": ""}
}
return map[string]interface{}{"error": "", "data": string(data)}
}
func (p *JSPlugin) fsWriteFile(path, content string) map[string]interface{} {
if err := p.sandbox.ValidateWritePath(path); err != nil {
return map[string]interface{}{"error": err.Error(), "ok": false}
}
if int64(len(content)) > p.sandbox.MaxWriteSize {
return map[string]interface{}{"error": "content too large", "ok": false}
}
err := os.WriteFile(path, []byte(content), 0644)
if err != nil {
return map[string]interface{}{"error": err.Error(), "ok": false}
}
return map[string]interface{}{"error": "", "ok": true}
}
func (p *JSPlugin) fsReadDir(path string) map[string]interface{} {
if err := p.sandbox.ValidateReadPath(path); err != nil {
return map[string]interface{}{"error": err.Error(), "entries": nil}
}
entries, err := os.ReadDir(path)
if err != nil {
return map[string]interface{}{"error": err.Error(), "entries": nil}
}
var result []map[string]interface{}
for _, e := range entries {
info, _ := e.Info()
result = append(result, map[string]interface{}{
"name": e.Name(),
"isDir": e.IsDir(),
"size": info.Size(),
})
}
return map[string]interface{}{"error": "", "entries": result}
}
func (p *JSPlugin) fsStat(path string) map[string]interface{} {
if err := p.sandbox.ValidateReadPath(path); err != nil {
return map[string]interface{}{"error": err.Error()}
}
info, err := os.Stat(path)
if err != nil {
return map[string]interface{}{"error": err.Error()}
}
return map[string]interface{}{
"error": "",
"name": info.Name(),
"size": info.Size(),
"isDir": info.IsDir(),
"modTime": info.ModTime().Unix(),
}
}
func (p *JSPlugin) fsExists(path string) map[string]interface{} {
if err := p.sandbox.ValidateReadPath(path); err != nil {
return map[string]interface{}{"error": err.Error(), "exists": false}
}
_, err := os.Stat(path)
return map[string]interface{}{"error": "", "exists": err == nil}
}
func (p *JSPlugin) fsMkdir(path string) map[string]interface{} {
if err := p.sandbox.ValidateWritePath(path); err != nil {
return map[string]interface{}{"error": err.Error(), "ok": false}
}
err := os.MkdirAll(path, 0755)
if err != nil {
return map[string]interface{}{"error": err.Error(), "ok": false}
}
return map[string]interface{}{"error": "", "ok": true}
}
func (p *JSPlugin) fsRemove(path string) map[string]interface{} {
if err := p.sandbox.ValidateWritePath(path); err != nil {
return map[string]interface{}{"error": err.Error(), "ok": false}
}
err := os.RemoveAll(path)
if err != nil {
return map[string]interface{}{"error": err.Error(), "ok": false}
}
return map[string]interface{}{"error": "", "ok": true}
}
// =============================================================================
// HTTP 服务 API
// =============================================================================
// createHttpAPI 创建 HTTP API
func (p *JSPlugin) createHttpAPI() map[string]interface{} {
return map[string]interface{}{
"serve": p.httpServe,
"json": p.httpJSON,
"sendFile": p.httpSendFile,
}
}
// httpServe 启动 HTTP 服务处理连接
func (p *JSPlugin) httpServe(connObj interface{}, handler goja.Callable) {
// 从 jsConn 包装器中提取原始 net.Conn
var conn net.Conn
if jc, ok := connObj.(*jsConn); ok {
conn = jc.conn
} else if nc, ok := connObj.(net.Conn); ok {
conn = nc
} else {
fmt.Printf("[JS:%s] httpServe: invalid conn type: %T\n", p.name, connObj)
return
}
// 注意不要在这里关闭连接HandleConn 会负责关闭
// Use bufio to read the request properly
reader := bufio.NewReader(conn)
for {
// 1. Read Request Line
line, err := reader.ReadString('\n')
if err != nil {
if err != io.EOF {
fmt.Printf("[JS:%s] httpServe read error: %v\n", p.name, err)
}
return
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, " ")
if len(parts) < 2 {
fmt.Printf("[JS:%s] Invalid request line: %s\n", p.name, line)
return
}
method := parts[0]
path := parts[1]
fmt.Printf("[JS:%s] Request: %s %s\n", p.name, method, path)
// 2. Read Headers
headers := make(map[string]string)
contentLength := 0
for {
hLine, err := reader.ReadString('\n')
if err != nil {
break
}
hLine = strings.TrimSpace(hLine)
if hLine == "" {
break
}
if idx := strings.Index(hLine, ":"); idx > 0 {
key := strings.TrimSpace(hLine[:idx])
val := strings.TrimSpace(hLine[idx+1:])
headers[strings.ToLower(key)] = val
if strings.ToLower(key) == "content-length" {
contentLength, _ = strconv.Atoi(val)
}
}
}
// 3. Read Body
body := ""
if contentLength > 0 {
bodyBuf := make([]byte, contentLength)
if _, err := io.ReadFull(reader, bodyBuf); err == nil {
body = string(bodyBuf)
}
}
req := map[string]interface{}{
"method": method,
"path": path,
"headers": headers,
"body": body,
}
// 调用 JS handler 函数
result, err := handler(goja.Undefined(), p.vm.ToValue(req))
if err != nil {
fmt.Printf("[JS:%s] HTTP handler error: %v\n", p.name, err)
conn.Write([]byte("HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n"))
return
}
// 将结果转换为 map
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"))
continue
}
resp := make(map[string]interface{})
respObj := result.ToObject(p.vm)
for _, key := range respObj.Keys() {
val := respObj.Get(key)
resp[key] = val.Export()
}
writeHTTPResponse(conn, resp)
}
}
func (p *JSPlugin) httpJSON(data interface{}) string {
b, _ := json.Marshal(data)
return string(b)
}
func (p *JSPlugin) httpSendFile(connObj interface{}, filePath string) {
// 从 jsConn 包装器中提取原始 net.Conn
var conn net.Conn
if jc, ok := connObj.(*jsConn); ok {
conn = jc.conn
} else if nc, ok := connObj.(net.Conn); ok {
conn = nc
} else {
fmt.Printf("[JS:%s] httpSendFile: invalid conn type: %T\n", p.name, connObj)
return
}
f, err := os.Open(filePath)
if err != nil {
conn.Write([]byte("HTTP/1.1 404 Not Found\r\n\r\n"))
return
}
defer f.Close()
info, _ := f.Stat()
contentType := getContentType(filePath)
header := fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %d\r\n\r\n",
contentType, info.Size())
conn.Write([]byte(header))
io.Copy(conn, f)
}
// parseHTTPRequest is deprecated, logic moved to httpServe
func parseHTTPRequest(data []byte) map[string]interface{} {
return nil
}
// writeHTTPResponse 写入 HTTP 响应
func writeHTTPResponse(conn net.Conn, resp map[string]interface{}) {
status := 200
if s, ok := resp["status"].(int); ok {
status = s
}
body := ""
if b, ok := resp["body"].(string); ok {
body = b
}
contentType := "application/json"
if ct, ok := resp["contentType"].(string); ok {
contentType = ct
}
header := fmt.Sprintf("HTTP/1.1 %d OK\r\nContent-Type: %s\r\nContent-Length: %d\r\n\r\n",
status, contentType, len(body))
conn.Write([]byte(header + body))
}
func indexOf(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
func getContentType(path string) string {
ext := filepath.Ext(path)
types := map[string]string{
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".gif": "image/gif",
".txt": "text/plain",
}
if ct, ok := types[ext]; ok {
return ct
}
return "application/octet-stream"
}
// =============================================================================
// Logger API
// =============================================================================
func (p *JSPlugin) createLoggerAPI() map[string]interface{} {
return map[string]interface{}{
"info": func(msg string) { fmt.Printf("[JS:%s][INFO] %s\n", p.name, msg) },
"warn": func(msg string) { fmt.Printf("[JS:%s][WARN] %s\n", p.name, msg) },
"error": func(msg string) { fmt.Printf("[JS:%s][ERROR] %s\n", p.name, msg) },
}
}
// =============================================================================
// Config API Enhancements
// =============================================================================
func (p *JSPlugin) jsGetAllConfig() map[string]string {
if p.config == nil {
return map[string]string{}
}
return p.config
}
// =============================================================================
// Storage API
// =============================================================================
func (p *JSPlugin) createStorageAPI() map[string]interface{} {
return map[string]interface{}{
"get": p.storageGet,
"set": p.storageSet,
"delete": p.storageDelete,
"keys": p.storageKeys,
}
}
func (p *JSPlugin) loadStorage() map[string]interface{} {
data := make(map[string]interface{})
if _, err := os.Stat(p.storagePath); err == nil {
content, _ := os.ReadFile(p.storagePath)
json.Unmarshal(content, &data)
}
return data
}
func (p *JSPlugin) saveStorage(data map[string]interface{}) {
content, _ := json.MarshalIndent(data, "", " ")
os.WriteFile(p.storagePath, content, 0644)
}
func (p *JSPlugin) storageGet(key string, def interface{}) interface{} {
p.mu.Lock()
defer p.mu.Unlock()
data := p.loadStorage()
if v, ok := data[key]; ok {
return v
}
return def
}
func (p *JSPlugin) storageSet(key string, value interface{}) {
p.mu.Lock()
defer p.mu.Unlock()
data := p.loadStorage()
data[key] = value
p.saveStorage(data)
}
func (p *JSPlugin) storageDelete(key string) {
p.mu.Lock()
defer p.mu.Unlock()
data := p.loadStorage()
delete(data, key)
p.saveStorage(data)
}
func (p *JSPlugin) storageKeys() []string {
p.mu.Lock()
defer p.mu.Unlock()
data := p.loadStorage()
keys := make([]string, 0, len(data))
for k := range data {
keys = append(keys, k)
}
return keys
}
// =============================================================================
// Event API
// =============================================================================
func (p *JSPlugin) createEventAPI() map[string]interface{} {
return map[string]interface{}{
"on": p.eventOn,
"emit": p.eventEmit,
"off": p.eventOff,
}
}
func (p *JSPlugin) eventOn(event string, callback func(goja.Value)) {
p.mu.Lock()
defer p.mu.Unlock()
p.eventListeners[event] = append(p.eventListeners[event], callback)
}
func (p *JSPlugin) eventEmit(event string, data interface{}) {
p.mu.Lock()
listeners := p.eventListeners[event]
p.mu.Unlock() // 释放锁以允许回调中操作
val := p.vm.ToValue(data)
for _, cb := range listeners {
cb(val)
}
}
func (p *JSPlugin) eventOff(event string) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.eventListeners, event)
}
// =============================================================================
// Request API (HTTP Client)
// =============================================================================
func (p *JSPlugin) createRequestAPI() map[string]interface{} {
return map[string]interface{}{
"get": p.requestGet,
"post": p.requestPost,
}
}
func (p *JSPlugin) requestGet(url string) map[string]interface{} {
resp, err := http.Get(url)
if err != nil {
return map[string]interface{}{"error": err.Error(), "status": 0}
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return map[string]interface{}{
"status": resp.StatusCode,
"body": string(body),
"error": "",
}
}
func (p *JSPlugin) requestPost(url string, contentType, data string) map[string]interface{} {
resp, err := http.Post(url, contentType, strings.NewReader(data))
if err != nil {
return map[string]interface{}{"error": err.Error(), "status": 0}
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return map[string]interface{}{
"status": resp.StatusCode,
"body": string(body),
"error": "",
}
}
// =============================================================================
// Notify API
// =============================================================================
func (p *JSPlugin) createNotifyAPI() map[string]interface{} {
return map[string]interface{}{
"send": func(title, msg string) {
// 目前仅打印到日志,后续对接系统通知
fmt.Printf("[NOTIFY][%s] %s: %s\n", p.name, title, msg)
},
}
}
// =============================================================================
// Route API (用于 Web API 代理)
// =============================================================================
func (p *JSPlugin) createRouteAPI() map[string]interface{} {
return map[string]interface{}{
"handle": p.apiHandle,
"get": func(path string, handler goja.Callable) { p.apiRegister("GET", path, handler) },
"post": func(path string, handler goja.Callable) { p.apiRegister("POST", path, handler) },
"put": func(path string, handler goja.Callable) { p.apiRegister("PUT", path, handler) },
"delete": func(path string, handler goja.Callable) { p.apiRegister("DELETE", path, handler) },
}
}
// apiHandle 注册 API 路由处理函数
func (p *JSPlugin) apiHandle(method, path string, handler goja.Callable) {
p.apiRegister(method, path, handler)
}
// apiRegister 注册 API 路由
func (p *JSPlugin) apiRegister(method, path string, handler goja.Callable) {
p.mu.Lock()
defer p.mu.Unlock()
if p.apiHandlers[method] == nil {
p.apiHandlers[method] = make(map[string]goja.Callable)
}
p.apiHandlers[method][path] = handler
fmt.Printf("[JS:%s] Registered API: %s %s\n", p.name, method, path)
}
// HandleAPIRequest 处理 API 请求
func (p *JSPlugin) HandleAPIRequest(method, path, query string, headers map[string]string, body string) (int, map[string]string, string, error) {
p.mu.Lock()
handlers := p.apiHandlers[method]
p.mu.Unlock()
if handlers == nil {
return 404, nil, `{"error":"method not allowed"}`, nil
}
// 查找匹配的路由
var handler goja.Callable
var matchedPath string
for registeredPath, h := range handlers {
if matchRoute(registeredPath, path) {
handler = h
matchedPath = registeredPath
break
}
}
if handler == nil {
return 404, nil, `{"error":"route not found"}`, nil
}
// 构建请求对象
reqObj := map[string]interface{}{
"method": method,
"path": path,
"pattern": matchedPath,
"query": query,
"headers": headers,
"body": body,
"params": extractParams(matchedPath, path),
}
// 调用处理函数
result, err := handler(goja.Undefined(), p.vm.ToValue(reqObj))
if err != nil {
return 500, nil, fmt.Sprintf(`{"error":"%s"}`, err.Error()), nil
}
// 解析响应
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return 200, nil, "", nil
}
respObj := result.ToObject(p.vm)
status := 200
if s := respObj.Get("status"); s != nil && !goja.IsUndefined(s) {
status = int(s.ToInteger())
}
respHeaders := make(map[string]string)
if h := respObj.Get("headers"); h != nil && !goja.IsUndefined(h) {
hObj := h.ToObject(p.vm)
for _, key := range hObj.Keys() {
respHeaders[key] = hObj.Get(key).String()
}
}
respBody := ""
if b := respObj.Get("body"); b != nil && !goja.IsUndefined(b) {
respBody = b.String()
}
return status, respHeaders, respBody, nil
}
// matchRoute 匹配路由 (支持简单的路径参数)
func matchRoute(pattern, path string) bool {
patternParts := strings.Split(strings.Trim(pattern, "/"), "/")
pathParts := strings.Split(strings.Trim(path, "/"), "/")
if len(patternParts) != len(pathParts) {
return false
}
for i, part := range patternParts {
if strings.HasPrefix(part, ":") {
continue // 路径参数,匹配任意值
}
if part != pathParts[i] {
return false
}
}
return true
}
// extractParams 提取路径参数
func extractParams(pattern, path string) map[string]string {
params := make(map[string]string)
patternParts := strings.Split(strings.Trim(pattern, "/"), "/")
pathParts := strings.Split(strings.Trim(path, "/"), "/")
for i, part := range patternParts {
if strings.HasPrefix(part, ":") && i < len(pathParts) {
paramName := strings.TrimPrefix(part, ":")
params[paramName] = pathParts[i]
}
}
return params
}

View File

@@ -1,161 +0,0 @@
package script
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Sandbox 插件沙箱配置
type Sandbox struct {
// 允许访问的路径列表(绝对路径)
AllowedPaths []string
// 允许写入的路径列表(必须是 AllowedPaths 的子集)
WritablePaths []string
// 禁止访问的路径(黑名单,优先级高于白名单)
DeniedPaths []string
// 是否允许网络访问
AllowNetwork bool
// 最大文件读取大小 (bytes)
MaxReadSize int64
// 最大文件写入大小 (bytes)
MaxWriteSize int64
// 最大内存使用量 (bytes)0 表示不限制
MaxMemory int64
// 最大调用栈深度
MaxStackDepth int
}
// DefaultSandbox 返回默认沙箱配置(最小权限)
func DefaultSandbox() *Sandbox {
return &Sandbox{
AllowedPaths: []string{},
WritablePaths: []string{},
DeniedPaths: defaultDeniedPaths(),
AllowNetwork: false,
MaxReadSize: 10 * 1024 * 1024, // 10MB
MaxWriteSize: 1 * 1024 * 1024, // 1MB
MaxMemory: 64 * 1024 * 1024, // 64MB
MaxStackDepth: 1000, // 最大调用栈深度
}
}
// defaultDeniedPaths 返回默认禁止访问的路径
func defaultDeniedPaths() []string {
home, _ := os.UserHomeDir()
denied := []string{
"/etc/passwd",
"/etc/shadow",
"/etc/sudoers",
"/root",
"/.ssh",
"/.gnupg",
"/.aws",
"/.kube",
"/proc",
"/sys",
}
if home != "" {
denied = append(denied,
filepath.Join(home, ".ssh"),
filepath.Join(home, ".gnupg"),
filepath.Join(home, ".aws"),
filepath.Join(home, ".kube"),
filepath.Join(home, ".config"),
filepath.Join(home, ".local"),
)
}
return denied
}
// ValidateReadPath 验证读取路径是否允许
func (s *Sandbox) ValidateReadPath(path string) error {
return s.validatePath(path, false)
}
// ValidateWritePath 验证写入路径是否允许
func (s *Sandbox) ValidateWritePath(path string) error {
return s.validatePath(path, true)
}
func (s *Sandbox) validatePath(path string, write bool) error {
// 清理路径,防止路径遍历攻击
cleanPath, err := s.cleanPath(path)
if err != nil {
return err
}
// 检查黑名单(优先级最高)
if s.isDenied(cleanPath) {
return fmt.Errorf("access denied: path is in denied list")
}
// 检查白名单
allowedList := s.AllowedPaths
if write {
allowedList = s.WritablePaths
}
if len(allowedList) == 0 {
return fmt.Errorf("access denied: no paths allowed")
}
if !s.isAllowed(cleanPath, allowedList) {
if write {
return fmt.Errorf("access denied: path not in writable list")
}
return fmt.Errorf("access denied: path not in allowed list")
}
return nil
}
// cleanPath 清理并验证路径
func (s *Sandbox) cleanPath(path string) (string, error) {
// 转换为绝对路径
absPath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
// 清理路径(解析 .. 和 .
cleanPath := filepath.Clean(absPath)
// 检查符号链接(防止通过符号链接绕过限制)
realPath, err := filepath.EvalSymlinks(cleanPath)
if err != nil {
// 文件可能不存在,使用清理后的路径
if !os.IsNotExist(err) {
return "", fmt.Errorf("invalid path: %w", err)
}
realPath = cleanPath
}
// 再次检查路径遍历
if strings.Contains(realPath, "..") {
return "", fmt.Errorf("path traversal detected")
}
return realPath, nil
}
// isDenied 检查路径是否在黑名单中
func (s *Sandbox) isDenied(path string) bool {
for _, denied := range s.DeniedPaths {
if strings.HasPrefix(path, denied) || path == denied {
return true
}
}
return false
}
// isAllowed 检查路径是否在白名单中
func (s *Sandbox) isAllowed(path string, allowedList []string) bool {
for _, allowed := range allowedList {
if strings.HasPrefix(path, allowed) || path == allowed {
return true
}
}
return false
}

View File

@@ -1,31 +0,0 @@
package sign
import (
"crypto/ed25519"
"sync"
)
// 官方固定公钥(客户端内置)
const OfficialPublicKeyBase64 = "0A0xRthj0wgPg8X8GJZ6/EnNpAUw5v7O//XLty+P5Yw="
var (
officialPubKey ed25519.PublicKey
officialPubKeyOnce sync.Once
officialPubKeyErr error
)
// initOfficialKey 初始化官方公钥
func initOfficialKey() {
officialPubKey, officialPubKeyErr = DecodePublicKey(OfficialPublicKeyBase64)
}
// GetOfficialPublicKey 获取官方公钥
func GetOfficialPublicKey() (ed25519.PublicKey, error) {
officialPubKeyOnce.Do(initOfficialKey)
return officialPubKey, officialPubKeyErr
}
// GetPublicKeyByID 根据 ID 获取公钥(兼容旧接口,忽略 keyID
func GetPublicKeyByID(keyID string) (ed25519.PublicKey, error) {
return GetOfficialPublicKey()
}

View File

@@ -1,107 +0,0 @@
package sign
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
)
// PluginPayload 插件签名载荷
type PluginPayload struct {
Name string `json:"name"` // 插件名称
Version string `json:"version"` // 版本号
SourceHash string `json:"source_hash"` // 源码 SHA256
KeyID string `json:"key_id"` // 签名密钥 ID
Timestamp int64 `json:"timestamp"` // 签名时间戳
}
// SignedPlugin 已签名的插件
type SignedPlugin struct {
Payload PluginPayload `json:"payload"`
Signature string `json:"signature"` // Base64 签名
}
// NormalizeSource 规范化源码(统一换行符)
func NormalizeSource(source string) string {
// 统一换行符为 LF
normalized := strings.ReplaceAll(source, "\r\n", "\n")
normalized = strings.ReplaceAll(normalized, "\r", "\n")
// 去除尾部空白
normalized = strings.TrimRight(normalized, " \t\n")
return normalized
}
// HashSource 计算源码哈希
func HashSource(source string) string {
normalized := NormalizeSource(source)
hash := sha256.Sum256([]byte(normalized))
return hex.EncodeToString(hash[:])
}
// CreatePayload 创建签名载荷
func CreatePayload(name, version, source, keyID string) *PluginPayload {
return &PluginPayload{
Name: name,
Version: version,
SourceHash: HashSource(source),
KeyID: keyID,
Timestamp: time.Now().Unix(),
}
}
// SignPlugin 签名插件
func SignPlugin(priv ed25519.PrivateKey, payload *PluginPayload) (*SignedPlugin, error) {
// 序列化载荷
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal payload: %w", err)
}
// 签名
sig := SignBase64(priv, data)
return &SignedPlugin{
Payload: *payload,
Signature: sig,
}, nil
}
// VerifyPlugin 验证插件签名
func VerifyPlugin(pub ed25519.PublicKey, signed *SignedPlugin, source string) error {
// 验证源码哈希
expectedHash := HashSource(source)
if signed.Payload.SourceHash != expectedHash {
return fmt.Errorf("source hash mismatch")
}
// 序列化载荷
data, err := json.Marshal(signed.Payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
// 验证签名
return VerifyBase64(pub, data, signed.Signature)
}
// EncodeSignedPlugin 编码已签名插件为 JSON
func EncodeSignedPlugin(sp *SignedPlugin) (string, error) {
data, err := json.Marshal(sp)
if err != nil {
return "", err
}
return string(data), nil
}
// DecodeSignedPlugin 从 JSON 解码已签名插件
func DecodeSignedPlugin(data string) (*SignedPlugin, error) {
var sp SignedPlugin
if err := json.Unmarshal([]byte(data), &sp); err != nil {
return nil, err
}
return &sp, nil
}

View File

@@ -1,92 +0,0 @@
package sign
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
)
var (
ErrInvalidSignature = errors.New("invalid signature")
ErrInvalidPublicKey = errors.New("invalid public key")
ErrInvalidPrivateKey = errors.New("invalid private key")
)
// KeyPair Ed25519 密钥对
type KeyPair struct {
PublicKey ed25519.PublicKey
PrivateKey ed25519.PrivateKey
}
// GenerateKeyPair 生成新的密钥对
func GenerateKeyPair() (*KeyPair, error) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate key: %w", err)
}
return &KeyPair{PublicKey: pub, PrivateKey: priv}, nil
}
// Sign 使用私钥签名数据
func Sign(privateKey ed25519.PrivateKey, data []byte) []byte {
return ed25519.Sign(privateKey, data)
}
// Verify 使用公钥验证签名
func Verify(publicKey ed25519.PublicKey, data, signature []byte) bool {
return ed25519.Verify(publicKey, data, signature)
}
// SignBase64 签名并返回 Base64 编码
func SignBase64(privateKey ed25519.PrivateKey, data []byte) string {
sig := Sign(privateKey, data)
return base64.StdEncoding.EncodeToString(sig)
}
// VerifyBase64 验证 Base64 编码的签名
func VerifyBase64(publicKey ed25519.PublicKey, data []byte, sigB64 string) error {
sig, err := base64.StdEncoding.DecodeString(sigB64)
if err != nil {
return fmt.Errorf("decode signature: %w", err)
}
if !Verify(publicKey, data, sig) {
return ErrInvalidSignature
}
return nil
}
// EncodePublicKey 编码公钥为 Base64
func EncodePublicKey(pub ed25519.PublicKey) string {
return base64.StdEncoding.EncodeToString(pub)
}
// DecodePublicKey 从 Base64 解码公钥
func DecodePublicKey(s string) (ed25519.PublicKey, error) {
data, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return nil, err
}
if len(data) != ed25519.PublicKeySize {
return nil, ErrInvalidPublicKey
}
return ed25519.PublicKey(data), nil
}
// EncodePrivateKey 编码私钥为 Base64
func EncodePrivateKey(priv ed25519.PrivateKey) string {
return base64.StdEncoding.EncodeToString(priv)
}
// DecodePrivateKey 从 Base64 解码私钥
func DecodePrivateKey(s string) (ed25519.PrivateKey, error) {
data, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return nil, err
}
if len(data) != ed25519.PrivateKeySize {
return nil, ErrInvalidPrivateKey
}
return ed25519.PrivateKey(data), nil
}

View File

@@ -1,47 +0,0 @@
package sign
import (
"strconv"
"strings"
)
// CompareVersions 比较两个版本号
// 返回: -1 (v1 < v2), 0 (v1 == v2), 1 (v1 > v2)
func CompareVersions(v1, v2 string) int {
parts1 := parseVersion(v1)
parts2 := parseVersion(v2)
maxLen := len(parts1)
if len(parts2) > maxLen {
maxLen = len(parts2)
}
for i := 0; i < maxLen; i++ {
var p1, p2 int
if i < len(parts1) {
p1 = parts1[i]
}
if i < len(parts2) {
p2 = parts2[i]
}
if p1 < p2 {
return -1
}
if p1 > p2 {
return 1
}
}
return 0
}
func parseVersion(v string) []int {
v = strings.TrimPrefix(v, "v")
parts := strings.Split(v, ".")
result := make([]int, len(parts))
for i, p := range parts {
n, _ := strconv.Atoi(p)
result[i] = n
}
return result
}

View File

@@ -1,110 +0,0 @@
package plugin
import (
"net"
"time"
)
// =============================================================================
// 基础类型
// =============================================================================
// Side 运行侧
type Side string
const (
SideClient Side = "client"
)
// PluginType 插件类别
type PluginType string
const (
PluginTypeProxy PluginType = "proxy" // 代理协议 (SOCKS5 等)
PluginTypeApp PluginType = "app" // 应用服务 (VNC, Echo 等)
)
// PluginSource 插件来源
type PluginSource string
const (
PluginSourceBuiltin PluginSource = "builtin" // 内置编译
PluginSourceScript PluginSource = "script" // 脚本插件
)
// =============================================================================
// 配置相关
// =============================================================================
// ConfigFieldType 配置字段类型
type ConfigFieldType string
const (
ConfigFieldString ConfigFieldType = "string"
ConfigFieldNumber ConfigFieldType = "number"
ConfigFieldBool ConfigFieldType = "bool"
ConfigFieldSelect ConfigFieldType = "select"
ConfigFieldPassword ConfigFieldType = "password"
)
// ConfigField 配置字段定义
type ConfigField struct {
Key string `json:"key"`
Label string `json:"label"`
Type ConfigFieldType `json:"type"`
Default string `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
Options []string `json:"options,omitempty"`
Description string `json:"description,omitempty"`
}
// RuleSchema 规则表单模式
type RuleSchema struct {
NeedsLocalAddr bool `json:"needs_local_addr"`
ExtraFields []ConfigField `json:"extra_fields,omitempty"`
}
// =============================================================================
// 元数据
// =============================================================================
// Metadata 插件元数据
type Metadata struct {
Name string `json:"name"`
Version string `json:"version"`
Type PluginType `json:"type"`
Source PluginSource `json:"source"`
RunAt Side `json:"run_at"`
Description string `json:"description"`
Author string `json:"author,omitempty"`
ConfigSchema []ConfigField `json:"config_schema,omitempty"`
RuleSchema *RuleSchema `json:"rule_schema,omitempty"`
}
// Info 插件运行时信息
type Info struct {
Metadata Metadata `json:"metadata"`
Loaded bool `json:"loaded"`
Enabled bool `json:"enabled"`
LoadedAt time.Time `json:"loaded_at,omitempty"`
Error string `json:"error,omitempty"`
}
// =============================================================================
// 核心接口
// =============================================================================
// Dialer 网络拨号接口
type Dialer interface {
Dial(network, address string) (net.Conn, error)
}
// ClientPlugin 客户端插件接口
// 运行在客户端,提供本地服务
type ClientPlugin interface {
Metadata() Metadata
Init(config map[string]string) error
Start() (localAddr string, err error)
HandleConn(conn net.Conn) error
Stop() error
}

View File

@@ -26,34 +26,11 @@ const (
MsgTypeProxyConnect uint8 = 9 // 代理连接请求 (SOCKS5/HTTP)
MsgTypeProxyResult uint8 = 10 // 代理连接结果
// Plugin 相关消息
MsgTypePluginList uint8 = 20 // 请求/响应可用 plugins
MsgTypePluginDownload uint8 = 21 // 请求下载 plugin
MsgTypePluginData uint8 = 22 // Plugin 二进制数据(分块)
MsgTypePluginReady uint8 = 23 // Plugin 加载确认
// UDP 相关消息
MsgTypeUDPData uint8 = 30 // UDP 数据包
// 插件安装消息
MsgTypeInstallPlugins uint8 = 24 // 服务端推送安装插件列表
MsgTypePluginConfig uint8 = 25 // 插件配置同步
// 客户端插件消息
MsgTypeClientPluginStart uint8 = 40 // 启动客户端插件
MsgTypeClientPluginStop uint8 = 41 // 停止客户端插件
MsgTypeClientPluginStatus uint8 = 42 // 客户端插件状态
MsgTypeClientPluginConn uint8 = 43 // 客户端插件连接请求
MsgTypePluginStatusQuery uint8 = 44 // 查询所有插件状态
MsgTypePluginStatusQueryResp uint8 = 45 // 插件状态查询响应
// JS 插件动态安装
MsgTypeJSPluginInstall uint8 = 50 // 安装 JS 插件
MsgTypeJSPluginResult uint8 = 51 // 安装结果
// 客户端控制消息
MsgTypeClientRestart uint8 = 60 // 重启客户端
MsgTypePluginConfigUpdate uint8 = 61 // 更新插件配置
MsgTypeClientRestart uint8 = 60 // 重启客户端
// 更新相关消息
MsgTypeUpdateCheck uint8 = 70 // 检查更新请求
@@ -68,13 +45,17 @@ const (
MsgTypeLogData uint8 = 81 // 日志数据
MsgTypeLogStop uint8 = 82 // 停止日志流
// 插件 API 路由消息
MsgTypePluginAPIRequest uint8 = 90 // 插件 API 请求
MsgTypePluginAPIResponse uint8 = 91 // 插件 API 响应
// 系统状态消息
MsgTypeSystemStatsRequest uint8 = 100 // 请求系统状态
MsgTypeSystemStatsResponse uint8 = 101 // 系统状态响应
// 截图消息
MsgTypeScreenshotRequest uint8 = 102 // 请求截图
MsgTypeScreenshotResponse uint8 = 103 // 截图响应
// Shell 执行消息
MsgTypeShellExecuteRequest uint8 = 104 // 执行 Shell 命令
MsgTypeShellExecuteResponse uint8 = 105 // Shell 执行结果
)
// Message 基础消息结构
@@ -87,6 +68,7 @@ type Message struct {
type AuthRequest struct {
ClientID string `json:"client_id"`
Token string `json:"token"`
Name string `json:"name,omitempty"` // 客户端名称(主机名)
OS string `json:"os,omitempty"` // 客户端操作系统
Arch string `json:"arch,omitempty"` // 客户端架构
Version string `json:"version,omitempty"` // 客户端版本
@@ -102,22 +84,17 @@ type AuthResponse struct {
// ProxyRule 代理规则
type ProxyRule struct {
Name string `json:"name" yaml:"name"`
Type string `json:"type" yaml:"type"` // 内置: tcp, udp, http, https, websocket; 插件: socks5
Type string `json:"type" yaml:"type"` // tcp, udp, http, https, socks5
LocalIP string `json:"local_ip" yaml:"local_ip"` // tcp/udp 模式使用
LocalPort int `json:"local_port" yaml:"local_port"` // tcp/udp 模式使用
RemotePort int `json:"remote_port" yaml:"remote_port"` // 服务端监听端口
Enabled *bool `json:"enabled,omitempty" yaml:"enabled"` // 是否启用,默认为 true
// Plugin 支持字段
PluginID string `json:"plugin_id,omitempty" yaml:"plugin_id"` // 插件实例ID
PluginName string `json:"plugin_name,omitempty" yaml:"plugin_name"`
PluginVersion string `json:"plugin_version,omitempty" yaml:"plugin_version"`
PluginConfig map[string]string `json:"plugin_config,omitempty" yaml:"plugin_config"`
// HTTP Basic Auth 字段 (用于独立端口模式)
// HTTP Basic Auth 字段
AuthEnabled bool `json:"auth_enabled,omitempty" yaml:"auth_enabled"`
AuthUsername string `json:"auth_username,omitempty" yaml:"auth_username"`
AuthPassword string `json:"auth_password,omitempty" yaml:"auth_password"`
// 插件管理标记 - 由插件自动创建的规则,不允许手动编辑/删除
PluginManaged bool `json:"plugin_managed,omitempty" yaml:"plugin_managed"`
// 端口状态: "listening", "failed: <error message>", ""
PortStatus string `json:"port_status,omitempty" yaml:"-"`
}
// IsEnabled 检查规则是否启用,默认为 true
@@ -155,60 +132,6 @@ type ProxyConnectResult struct {
Message string `json:"message,omitempty"`
}
// PluginMetadata Plugin 元数据(协议层)
type PluginMetadata struct {
Name string `json:"name"`
Version string `json:"version"`
Checksum string `json:"checksum"`
Size int64 `json:"size"`
Description string `json:"description,omitempty"`
}
// PluginListRequest 请求可用 plugins
type PluginListRequest struct {
ClientVersion string `json:"client_version"`
}
// PluginListResponse 返回可用 plugins
type PluginListResponse struct {
Plugins []PluginMetadata `json:"plugins"`
}
// PluginDownloadRequest 请求下载 plugin
type PluginDownloadRequest struct {
Name string `json:"name"`
Version string `json:"version"`
}
// PluginDataChunk Plugin 二进制数据块
type PluginDataChunk struct {
Name string `json:"name"`
Version string `json:"version"`
ChunkIndex int `json:"chunk_index"`
TotalChunks int `json:"total_chunks"`
Data []byte `json:"data"`
Checksum string `json:"checksum,omitempty"`
}
// PluginReadyNotification Plugin 加载确认
type PluginReadyNotification struct {
Name string `json:"name"`
Version string `json:"version"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// InstallPluginsRequest 安装插件请求
type InstallPluginsRequest struct {
Plugins []string `json:"plugins"` // 要安装的插件名称列表
}
// PluginConfigSync 插件配置同步
type PluginConfigSync struct {
PluginName string `json:"plugin_name"` // 插件名称
Config map[string]string `json:"config"` // 配置内容
}
// UDPPacket UDP 数据包
type UDPPacket struct {
RemotePort int `json:"remote_port"` // 服务端监听端口
@@ -216,67 +139,6 @@ type UDPPacket struct {
Data []byte `json:"data"` // UDP 数据
}
// ClientPluginStartRequest 启动客户端插件请求
type ClientPluginStartRequest struct {
PluginName string `json:"plugin_name"` // 插件名称
RuleName string `json:"rule_name"` // 规则名称
RemotePort int `json:"remote_port"` // 服务端监听端口
Config map[string]string `json:"config"` // 插件配置
}
// ClientPluginStopRequest 停止客户端插件请求
type ClientPluginStopRequest struct {
PluginID string `json:"plugin_id,omitempty"` // 插件ID优先使用
PluginName string `json:"plugin_name"` // 插件名称
RuleName string `json:"rule_name"` // 规则名称
}
// ClientPluginStatusResponse 客户端插件状态响应
type ClientPluginStatusResponse struct {
PluginName string `json:"plugin_name"` // 插件名称
RuleName string `json:"rule_name"` // 规则名称
Running bool `json:"running"` // 是否运行中
LocalAddr string `json:"local_addr"` // 本地监听地址
Error string `json:"error"` // 错误信息
}
// ClientPluginConnRequest 客户端插件连接请求
type ClientPluginConnRequest struct {
PluginID string `json:"plugin_id,omitempty"` // 插件ID优先使用
PluginName string `json:"plugin_name"` // 插件名称
RuleName string `json:"rule_name"` // 规则名称
}
// PluginStatusEntry 单个插件状态
type PluginStatusEntry struct {
PluginName string `json:"plugin_name"` // 插件名称
Running bool `json:"running"` // 是否运行中
}
// PluginStatusQueryResponse 插件状态查询响应
type PluginStatusQueryResponse struct {
Plugins []PluginStatusEntry `json:"plugins"` // 所有插件状态
}
// JSPluginInstallRequest JS 插件安装请求
type JSPluginInstallRequest struct {
PluginID string `json:"plugin_id"` // 插件实例唯一 ID
PluginName string `json:"plugin_name"` // 插件名称
Source string `json:"source"` // JS 源码
Signature string `json:"signature"` // 官方签名 (Base64)
RuleName string `json:"rule_name"` // 规则名称
RemotePort int `json:"remote_port"` // 服务端监听端口
Config map[string]string `json:"config"` // 插件配置
AutoStart bool `json:"auto_start"` // 是否自动启动
}
// JSPluginInstallResult JS 插件安装结果
type JSPluginInstallResult struct {
PluginName string `json:"plugin_name"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// ClientRestartRequest 客户端重启请求
type ClientRestartRequest struct {
Reason string `json:"reason,omitempty"` // 重启原因
@@ -288,23 +150,6 @@ type ClientRestartResponse struct {
Message string `json:"message,omitempty"`
}
// PluginConfigUpdateRequest 插件配置更新请求
type PluginConfigUpdateRequest struct {
PluginID string `json:"plugin_id,omitempty"` // 插件ID优先使用
PluginName string `json:"plugin_name"` // 插件名称
RuleName string `json:"rule_name"` // 规则名称
Config map[string]string `json:"config"` // 新配置
Restart bool `json:"restart"` // 是否重启插件
}
// PluginConfigUpdateResponse 插件配置更新响应
type PluginConfigUpdateResponse struct {
PluginName string `json:"plugin_name"`
RuleName string `json:"rule_name"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// UpdateCheckRequest 更新检查请求
type UpdateCheckRequest struct {
Component string `json:"component"` // "server" 或 "client"
@@ -358,7 +203,7 @@ type LogEntry struct {
Timestamp int64 `json:"ts"` // Unix 时间戳 (毫秒)
Level string `json:"level"` // 日志级别: debug, info, warn, error
Message string `json:"msg"` // 日志消息
Source string `json:"src"` // 来源: client, plugin:<name>
Source string `json:"src"` // 来源: client
}
// LogData 日志数据
@@ -373,25 +218,6 @@ type LogStopRequest struct {
SessionID string `json:"session_id"` // 会话 ID
}
// PluginAPIRequest 插件 API 请求
type PluginAPIRequest struct {
PluginID string `json:"plugin_id"` // 插件实例唯一 ID
PluginName string `json:"plugin_name"` // 插件名称 (向后兼容)
Method string `json:"method"` // HTTP 方法: GET, POST, PUT, DELETE
Path string `json:"path"` // 路由路径
Query string `json:"query"` // 查询参数
Headers map[string]string `json:"headers"` // 请求头
Body string `json:"body"` // 请求体
}
// PluginAPIResponse 插件 API 响应
type PluginAPIResponse struct {
Status int `json:"status"` // HTTP 状态码
Headers map[string]string `json:"headers"` // 响应头
Body string `json:"body"` // 响应体
Error string `json:"error"` // 错误信息
}
// WriteMessage 写入消息到 writer
func WriteMessage(w io.Writer, msg *Message) error {
header := make([]byte, HeaderSize)
@@ -460,3 +286,30 @@ type SystemStatsResponse struct {
DiskUsed uint64 `json:"disk_used"` // 已用磁盘 (字节)
DiskUsage float64 `json:"disk_usage"` // 磁盘使用率 (0-100)
}
// ScreenshotRequest 截图请求
type ScreenshotRequest struct {
Quality int `json:"quality"` // JPEG 质量 1-100, 0 使用默认值
}
// ScreenshotResponse 截图响应
type ScreenshotResponse struct {
Data string `json:"data"` // Base64 编码的 JPEG 图片
Width int `json:"width"` // 图片宽度
Height int `json:"height"` // 图片高度
Timestamp int64 `json:"timestamp"` // 截图时间戳
Error string `json:"error,omitempty"` // 错误信息
}
// ShellExecuteRequest Shell 执行请求
type ShellExecuteRequest struct {
Command string `json:"command"` // 要执行的命令
Timeout int `json:"timeout"` // 超时秒数, 0 使用默认值 (30秒)
}
// ShellExecuteResponse Shell 执行响应
type ShellExecuteResponse struct {
Output string `json:"output"` // stdout + stderr 组合输出
ExitCode int `json:"exit_code"` // 进程退出码
Error string `json:"error,omitempty"` // 错误信息
}

View File

@@ -2,20 +2,27 @@ package proxy
import (
"bufio"
"encoding/base64"
"errors"
"io"
"net"
"net/http"
"strings"
"github.com/gotunnel/pkg/relay"
)
// HTTPServer HTTP 代理服务
type HTTPServer struct {
dialer Dialer
dialer Dialer
onStats func(in, out int64) // 流量统计回调
username string
password string
}
// NewHTTPServer 创建 HTTP 代理服务
func NewHTTPServer(dialer Dialer) *HTTPServer {
return &HTTPServer{dialer: dialer}
func NewHTTPServer(dialer Dialer, onStats func(in, out int64), username, password string) *HTTPServer {
return &HTTPServer{dialer: dialer, onStats: onStats, username: username, password: password}
}
// HandleConn 处理 HTTP 代理连接
@@ -28,12 +35,45 @@ func (h *HTTPServer) HandleConn(conn net.Conn) error {
return err
}
// 检查认证
if h.username != "" && h.password != "" {
if !h.checkAuth(req) {
conn.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"proxy\"\r\n\r\n"))
return errors.New("authentication required")
}
}
if req.Method == http.MethodConnect {
return h.handleConnect(conn, req)
}
return h.handleHTTP(conn, req, reader)
}
// checkAuth 检查 Proxy-Authorization 头
func (h *HTTPServer) checkAuth(req *http.Request) bool {
auth := req.Header.Get("Proxy-Authorization")
if auth == "" {
return false
}
const prefix = "Basic "
if !strings.HasPrefix(auth, prefix) {
return false
}
decoded, err := base64.StdEncoding.DecodeString(auth[len(prefix):])
if err != nil {
return false
}
credentials := strings.SplitN(string(decoded), ":", 2)
if len(credentials) != 2 {
return false
}
return credentials[0] == h.username && credentials[1] == h.password
}
// handleConnect 处理 CONNECT 方法 (HTTPS)
func (h *HTTPServer) handleConnect(conn net.Conn, req *http.Request) error {
target := req.Host
@@ -50,8 +90,8 @@ func (h *HTTPServer) handleConnect(conn net.Conn, req *http.Request) error {
conn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
go io.Copy(remote, conn)
io.Copy(conn, remote)
// 双向转发 (带流量统计)
relay.RelayWithStats(conn, remote, h.onStats)
return nil
}
@@ -82,7 +122,10 @@ func (h *HTTPServer) handleHTTP(conn net.Conn, req *http.Request, reader *bufio.
return err
}
// 转发响应
_, err = io.Copy(conn, remote)
// 转发响应 (带流量统计)
n, err := io.Copy(conn, remote)
if h.onStats != nil && n > 0 {
h.onStats(0, n) // 响应数据为出站流量
}
return err
}

View File

@@ -14,10 +14,10 @@ type Server struct {
}
// NewServer 创建代理服务器
func NewServer(typ string, dialer Dialer) *Server {
func NewServer(typ string, dialer Dialer, onStats func(in, out int64), username, password string) *Server {
return &Server{
socks5: NewSOCKS5Server(dialer),
http: NewHTTPServer(dialer),
socks5: NewSOCKS5Server(dialer, onStats, username, password),
http: NewHTTPServer(dialer, onStats, username, password),
typ: typ,
}
}

View File

@@ -6,11 +6,14 @@ import (
"fmt"
"io"
"net"
"github.com/gotunnel/pkg/relay"
)
const (
socks5Version = 0x05
noAuth = 0x00
userPassAuth = 0x02
cmdConnect = 0x01
atypIPv4 = 0x01
atypDomain = 0x03
@@ -19,7 +22,10 @@ const (
// SOCKS5Server SOCKS5 代理服务
type SOCKS5Server struct {
dialer Dialer
dialer Dialer
onStats func(in, out int64) // 流量统计回调
username string
password string
}
// Dialer 连接拨号器接口
@@ -28,8 +34,8 @@ type Dialer interface {
}
// NewSOCKS5Server 创建 SOCKS5 服务
func NewSOCKS5Server(dialer Dialer) *SOCKS5Server {
return &SOCKS5Server{dialer: dialer}
func NewSOCKS5Server(dialer Dialer, onStats func(in, out int64), username, password string) *SOCKS5Server {
return &SOCKS5Server{dialer: dialer, onStats: onStats, username: username, password: password}
}
// HandleConn 处理 SOCKS5 连接
@@ -60,9 +66,8 @@ func (s *SOCKS5Server) HandleConn(conn net.Conn) error {
return err
}
// 双向转发
go io.Copy(remote, conn)
io.Copy(conn, remote)
// 双向转发 (带流量统计)
relay.RelayWithStats(conn, remote, s.onStats)
return nil
}
@@ -83,11 +88,54 @@ func (s *SOCKS5Server) handshake(conn net.Conn) error {
return err
}
// 响应:使用无认证
// 如果配置了用户名密码,要求认证
if s.username != "" && s.password != "" {
_, err := conn.Write([]byte{socks5Version, userPassAuth})
if err != nil {
return err
}
return s.authenticate(conn)
}
// 无认证
_, err := conn.Write([]byte{socks5Version, noAuth})
return err
}
// authenticate 处理用户名密码认证
func (s *SOCKS5Server) authenticate(conn net.Conn) error {
buf := make([]byte, 2)
if _, err := io.ReadFull(conn, buf); err != nil {
return err
}
if buf[0] != 0x01 {
return errors.New("unsupported auth version")
}
ulen := int(buf[1])
username := make([]byte, ulen)
if _, err := io.ReadFull(conn, username); err != nil {
return err
}
plen := make([]byte, 1)
if _, err := io.ReadFull(conn, plen); err != nil {
return err
}
password := make([]byte, plen[0])
if _, err := io.ReadFull(conn, password); err != nil {
return err
}
if string(username) == s.username && string(password) == s.password {
conn.Write([]byte{0x01, 0x00}) // 认证成功
return nil
}
conn.Write([]byte{0x01, 0x01}) // 认证失败
return errors.New("authentication failed")
}
// readRequest 读取请求
func (s *SOCKS5Server) readRequest(conn net.Conn) (string, error) {
buf := make([]byte, 4)

View File

@@ -0,0 +1,82 @@
//go:build windows || linux || darwin
package utils
import (
"bytes"
"fmt"
"image"
"image/jpeg"
"github.com/kbinani/screenshot"
)
// CaptureScreenshot captures the primary display.
func CaptureScreenshot(quality int) ([]byte, int, int, error) {
if quality <= 0 || quality > 100 {
quality = 75
}
n := screenshot.NumActiveDisplays()
if n == 0 {
return nil, 0, 0, fmt.Errorf("no active display found")
}
bounds := screenshot.GetDisplayBounds(0)
if bounds.Empty() {
return nil, 0, 0, fmt.Errorf("failed to get display bounds")
}
img, err := screenshot.CaptureRect(bounds)
if err != nil {
return nil, 0, 0, fmt.Errorf("capture screen: %w", err)
}
var buf bytes.Buffer
opts := &jpeg.Options{Quality: quality}
if err := jpeg.Encode(&buf, img, opts); err != nil {
return nil, 0, 0, fmt.Errorf("encode jpeg: %w", err)
}
return buf.Bytes(), bounds.Dx(), bounds.Dy(), nil
}
// CaptureAllScreens captures all active displays and stitches them together.
func CaptureAllScreens(quality int) ([]byte, int, int, error) {
if quality <= 0 || quality > 100 {
quality = 75
}
n := screenshot.NumActiveDisplays()
if n == 0 {
return nil, 0, 0, fmt.Errorf("no active display found")
}
var totalBounds image.Rectangle
for i := 0; i < n; i++ {
bounds := screenshot.GetDisplayBounds(i)
totalBounds = totalBounds.Union(bounds)
}
totalImg := image.NewRGBA(totalBounds)
for i := 0; i < n; i++ {
bounds := screenshot.GetDisplayBounds(i)
img, err := screenshot.CaptureRect(bounds)
if err != nil {
continue
}
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
totalImg.Set(x, y, img.At(x-bounds.Min.X, y-bounds.Min.Y))
}
}
}
var buf bytes.Buffer
opts := &jpeg.Options{Quality: quality}
if err := jpeg.Encode(&buf, totalImg, opts); err != nil {
return nil, 0, 0, fmt.Errorf("encode jpeg: %w", err)
}
return buf.Bytes(), totalBounds.Dx(), totalBounds.Dy(), nil
}

View File

@@ -0,0 +1,15 @@
//go:build !windows && !linux && !darwin
package utils
import "fmt"
// CaptureScreenshot is not available on this platform.
func CaptureScreenshot(quality int) ([]byte, int, int, error) {
return nil, 0, 0, fmt.Errorf("screenshot not supported on this platform")
}
// CaptureAllScreens is not available on this platform.
func CaptureAllScreens(quality int) ([]byte, int, int, error) {
return nil, 0, 0, fmt.Errorf("screenshot not supported on this platform")
}

View File

@@ -11,37 +11,19 @@ import (
"time"
)
// 版本信息
var Version = "1.0.0"
var GitCommit = ""
var BuildTime = ""
// SetVersion 设置版本号(由 main 包在初始化时调用)
func SetVersion(v string) {
if v != "" {
Version = v
}
}
// SetBuildInfo 设置构建信息(由 main 包在初始化时调用)
func SetBuildInfo(gitCommit, buildTime string) {
if gitCommit != "" {
GitCommit = gitCommit
}
if buildTime != "" {
BuildTime = buildTime
}
}
// 仓库信息
const (
RepoURL = "https://git.92coco.cn/flik/GoTunnel"
APIBaseURL = "https://git.92coco.cn/api/v1"
RepoOwner = "flik"
RepoName = "GoTunnel"
RepoURL = "https://github.com/Flikify/Gotunnel"
APIBaseURL = "https://api.github.com"
RepoOwner = "Flikify"
RepoName = "Gotunnel"
GitHubAPIVersion = "2022-11-28"
GitHubUserAgent = "GoTunnel-Updater"
)
// Info 版本详细信息
type Info struct {
Version string `json:"version"`
GitCommit string `json:"git_commit"`
@@ -51,7 +33,43 @@ type Info struct {
Arch string `json:"arch"`
}
// GetInfo 获取版本信息
type ReleaseInfo struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
PublishedAt string `json:"published_at"`
Assets []ReleaseAsset `json:"assets"`
}
type ReleaseAsset struct {
Name string `json:"name"`
Size int64 `json:"size"`
BrowserDownloadURL string `json:"browser_download_url"`
}
type UpdateInfo struct {
Latest string `json:"latest"`
ReleaseNote string `json:"release_note"`
DownloadURL string `json:"download_url"`
AssetName string `json:"asset_name"`
AssetSize int64 `json:"asset_size"`
}
func SetVersion(v string) {
if v != "" {
Version = v
}
}
func SetBuildInfo(gitCommit, buildTime string) {
if gitCommit != "" {
GitCommit = gitCommit
}
if buildTime != "" {
BuildTime = buildTime
}
}
func GetInfo() Info {
return Info{
Version: Version,
@@ -63,39 +81,27 @@ func GetInfo() Info {
}
}
// ReleaseInfo Release 信息
type ReleaseInfo struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
PublishedAt string `json:"published_at"`
Assets []ReleaseAsset `json:"assets"`
func newGitHubRequest(url string) (*http.Request, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", GitHubAPIVersion)
req.Header.Set("User-Agent", GitHubUserAgent)
return req, nil
}
// ReleaseAsset Release 资产
type ReleaseAsset struct {
Name string `json:"name"`
Size int64 `json:"size"`
BrowserDownloadURL string `json:"browser_download_url"`
}
// UpdateInfo 更新信息
type UpdateInfo struct {
Latest string `json:"latest"`
ReleaseNote string `json:"release_note"`
DownloadURL string `json:"download_url"`
AssetName string `json:"asset_name"`
AssetSize int64 `json:"asset_size"`
}
// GetLatestRelease 获取最新 Release
// Gitea 兼容:先尝试 /releases/latest失败则尝试 /releases 取第一个
func GetLatestRelease() (*ReleaseInfo, error) {
client := &http.Client{Timeout: 30 * time.Second}
// 首先尝试 /releases/latest 端点GitHub 兼容)
latestURL := fmt.Sprintf("%s/repos/%s/%s/releases/latest", APIBaseURL, RepoOwner, RepoName)
resp, err := client.Get(latestURL)
req, err := newGitHubRequest(latestURL)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
@@ -109,10 +115,15 @@ func GetLatestRelease() (*ReleaseInfo, error) {
return &release, nil
}
// 如果 /releases/latest 不可用,尝试 /releases 并取第一个
resp.Body.Close()
listURL := fmt.Sprintf("%s/repos/%s/%s/releases?limit=1", APIBaseURL, RepoOwner, RepoName)
resp, err = client.Get(listURL)
listURL := fmt.Sprintf("%s/repos/%s/%s/releases?per_page=1", APIBaseURL, RepoOwner, RepoName)
req, err = newGitHubRequest(listURL)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err = client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
@@ -135,14 +146,12 @@ func GetLatestRelease() (*ReleaseInfo, error) {
return &releases[0], nil
}
// CheckUpdate 检查更新(返回最新版本信息)
func CheckUpdate(component string) (*UpdateInfo, error) {
release, err := GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
// 查找对应平台的资产
var downloadURL string
var assetName string
var assetSize int64
@@ -162,14 +171,12 @@ func CheckUpdate(component string) (*UpdateInfo, error) {
}, nil
}
// CheckUpdateForPlatform 检查指定平台的更新
func CheckUpdateForPlatform(component, osName, arch string) (*UpdateInfo, error) {
release, err := GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
// 查找对应平台的资产
var downloadURL string
var assetName string
var assetSize int64
@@ -189,17 +196,12 @@ func CheckUpdateForPlatform(component, osName, arch string) (*UpdateInfo, error)
}, nil
}
// findAssetForPlatform 在 Release 资产中查找匹配的文件
func findAssetForPlatform(assets []ReleaseAsset, component, osName, arch string) *ReleaseAsset {
// 构建匹配模式
// CI 格式: gotunnel-server-v1.0.0-linux-amd64.tar.gz
// 或者: gotunnel-client-v1.0.0-windows-amd64.zip
prefix := fmt.Sprintf("gotunnel-%s-", component)
suffix := fmt.Sprintf("-%s-%s", osName, arch)
for i := range assets {
name := assets[i].Name
// 检查是否匹配 gotunnel-{component}-{version}-{os}-{arch}.{ext}
if strings.HasPrefix(name, prefix) && strings.Contains(name, suffix) {
return &assets[i]
}
@@ -207,8 +209,6 @@ func findAssetForPlatform(assets []ReleaseAsset, component, osName, arch string)
return nil
}
// CompareVersions 比较版本号
// 返回: -1 (v1 < v2), 0 (v1 == v2), 1 (v1 > v2)
func CompareVersions(v1, v2 string) int {
parts1 := parseVersionParts(v1)
parts2 := parseVersionParts(v2)

View File

@@ -1,6 +1,6 @@
# GoTunnel Build Script for Windows
# Usage: .\build.ps1 [command]
# Commands: all, current, web, server, client, clean, help
# Commands: all, current, web, server, client, android, clean, help
param(
[Parameter(Position=0)]
@@ -13,15 +13,11 @@ param(
$ErrorActionPreference = "Stop"
# 项目根目录
$RootDir = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
if (-not $RootDir) {
$RootDir = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
}
$RootDir = Split-Path -Parent $PSScriptRoot
$BuildDir = Join-Path $RootDir "build"
$env:GOCACHE = if ($env:GOCACHE) { $env:GOCACHE } else { Join-Path $BuildDir ".gocache" }
# 版本信息
$BuildTime = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
$BuildTime = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss")
try {
$GitCommit = (git -C $RootDir rev-parse --short HEAD 2>$null)
if (-not $GitCommit) { $GitCommit = "unknown" }
@@ -29,17 +25,15 @@ try {
$GitCommit = "unknown"
}
# 目标平台
$Platforms = @(
@{OS="windows"; Arch="amd64"},
@{OS="linux"; Arch="amd64"},
@{OS="linux"; Arch="arm64"},
@{OS="darwin"; Arch="amd64"},
@{OS="darwin"; Arch="arm64"}
)
$DesktopPlatforms = @(
@{ OS = "windows"; Arch = "amd64" },
@{ OS = "windows"; Arch = "arm64" },
@{ OS = "linux"; Arch = "amd64" },
@{ OS = "linux"; Arch = "arm64" },
@{ OS = "darwin"; Arch = "amd64" },
@{ OS = "darwin"; Arch = "arm64" }
)
# 颜色输出函数
function Write-Info {
param([string]$Message)
Write-Host "[INFO] " -ForegroundColor Green -NoNewline
@@ -58,7 +52,6 @@ function Write-Err {
Write-Host $Message
}
# 检查 UPX 是否可用
function Test-UPX {
try {
$null = Get-Command upx -ErrorAction Stop
@@ -68,16 +61,17 @@ function Test-UPX {
}
}
# UPX 压缩二进制
function Compress-Binary {
param([string]$FilePath, [string]$OS)
param(
[string]$FilePath,
[string]$OS
)
if ($NoUPX) { return }
if (-not (Test-UPX)) {
Write-Warn "UPX not found, skipping compression"
return
}
# macOS 二进制不支持 UPX
if ($OS -eq "darwin") {
Write-Warn "Skipping UPX for macOS binary: $FilePath"
return
@@ -91,7 +85,6 @@ function Compress-Binary {
}
}
# 构建 Web UI
function Build-Web {
Write-Info "Building web UI..."
@@ -111,7 +104,6 @@ function Build-Web {
Pop-Location
}
# 复制到 embed 目录
Write-Info "Copying dist to embed directory..."
$DistSource = Join-Path $WebDir "dist"
$DistDest = Join-Path $RootDir "internal\server\app\dist"
@@ -124,51 +116,55 @@ function Build-Web {
Write-Info "Web UI built successfully"
}
# 构建单个二进制
function Get-OutputName {
param(
[string]$Component,
[string]$OS
)
if ($OS -eq "windows") {
return "$Component.exe"
}
return $Component
}
function Build-Binary {
param(
[string]$OS,
[string]$Arch,
[string]$Component # server 或 client
[string]$Component
)
$OutputName = $Component
if ($OS -eq "windows") {
$OutputName = "$Component.exe"
}
$OutputDir = Join-Path $BuildDir "${OS}_${Arch}"
if (-not (Test-Path $OutputDir)) {
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
}
$OutputName = Get-OutputName -Component $Component -OS $OS
$OutputPath = Join-Path $OutputDir $OutputName
$SourcePath = Join-Path $RootDir "cmd\$Component"
Write-Info "Building $Component for $OS/$Arch..."
$env:GOOS = $OS
$env:GOARCH = $Arch
$env:CGO_ENABLED = "0"
$LDFlags = "-s -w -X 'github.com/gotunnel/pkg/version.Version=$Version' -X 'github.com/gotunnel/pkg/version.BuildTime=$BuildTime' -X 'github.com/gotunnel/pkg/version.GitCommit=$GitCommit'"
$OutputPath = Join-Path $OutputDir $OutputName
$SourcePath = Join-Path $RootDir "cmd\$Component"
& go build -ldflags $LDFlags -o $OutputPath $SourcePath
$LdFlags = "-s -w -X 'main.Version=$Version' -X 'main.BuildTime=$BuildTime' -X 'main.GitCommit=$GitCommit'"
& go build -buildvcs=false -trimpath -ldflags $LdFlags -o $OutputPath $SourcePath
if ($LASTEXITCODE -ne 0) {
throw "Build failed for $Component $OS/$Arch"
}
# UPX 压缩
Compress-Binary -FilePath $OutputPath -OS $OS
# 显示文件大小
$FileSize = (Get-Item $OutputPath).Length / 1MB
Write-Info " -> $OutputPath ({0:N2} MB)" -f $FileSize
Write-Info (" -> {0} ({1:N2} MB)" -f $OutputPath, $FileSize)
}
# 构建所有平台
function Build-All {
foreach ($Platform in $Platforms) {
foreach ($Platform in $DesktopPlatforms) {
Build-Binary -OS $Platform.OS -Arch $Platform.Arch -Component "server"
Build-Binary -OS $Platform.OS -Arch $Platform.Arch -Component "client"
}
@@ -184,7 +180,6 @@ function Build-All {
}
}
# 仅构建当前平台
function Build-Current {
$OS = go env GOOS
$Arch = go env GOARCH
@@ -195,7 +190,51 @@ function Build-Current {
Write-Info "Binaries built in $BuildDir\${OS}_${Arch}\"
}
# 清理构建产物
function Build-Android {
$OutputDir = Join-Path $BuildDir "android_arm64"
if (-not (Test-Path $OutputDir)) {
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
}
Write-Info "Building client for android/arm64..."
$env:GOOS = "android"
$env:GOARCH = "arm64"
$env:CGO_ENABLED = "0"
$OutputPath = Join-Path $OutputDir "client"
$LdFlags = "-s -w -X 'main.Version=$Version' -X 'main.BuildTime=$BuildTime' -X 'main.GitCommit=$GitCommit'"
& go build -buildvcs=false -trimpath -ldflags $LdFlags -o $OutputPath (Join-Path $RootDir "cmd\client")
if ($LASTEXITCODE -ne 0) {
throw "Build failed for client android/arm64"
}
if (Get-Command gomobile -ErrorAction SilentlyContinue) {
Write-Info "Building gomobile Android binding..."
& gomobile bind -target android/arm64 -o (Join-Path $OutputDir "gotunnelmobile.aar") "github.com/gotunnel/mobile/gotunnelmobile"
if ($LASTEXITCODE -ne 0) {
throw "gomobile bind failed"
}
} else {
Write-Warn "gomobile not found, skipping Android AAR build"
}
$GradleWrapper = Join-Path $RootDir "android\gradlew.bat"
if (Test-Path $GradleWrapper) {
Write-Info "Building Android debug APK..."
Push-Location (Join-Path $RootDir "android")
try {
& $GradleWrapper assembleDebug
if ($LASTEXITCODE -ne 0) {
throw "Android APK build failed"
}
} finally {
Pop-Location
}
} else {
Write-Warn "android\\gradlew.bat not found, skipping APK build"
}
}
function Clean-Build {
Write-Info "Cleaning build directory..."
if (Test-Path $BuildDir) {
@@ -204,7 +243,6 @@ function Clean-Build {
Write-Info "Clean completed"
}
# 显示帮助
function Show-Help {
Write-Host @"
GoTunnel Build Script for Windows
@@ -212,11 +250,12 @@ GoTunnel Build Script for Windows
Usage: .\build.ps1 [command] [-Version <version>] [-NoUPX]
Commands:
all Build web UI + all platforms (default)
all Build web UI + all desktop platforms (default)
current Build web UI + current platform only
web Build web UI only
server Build server for current platform
client Build client for current platform
android Build android/arm64 client and optional Android artifacts
clean Clean build directory
help Show this help message
@@ -226,20 +265,20 @@ Options:
Target platforms:
- windows/amd64
- windows/arm64
- linux/amd64
- linux/arm64
- darwin/amd64 (macOS Intel)
- darwin/arm64 (macOS Apple Silicon)
- darwin/amd64
- darwin/arm64
Examples:
.\build.ps1 # Build all platforms
.\build.ps1 # Build all desktop platforms
.\build.ps1 all -Version 1.0.0 # Build with version
.\build.ps1 current # Build current platform only
.\build.ps1 clean # Clean build directory
"@
}
# 主函数
function Main {
Push-Location $RootDir
@@ -270,10 +309,13 @@ function Main {
$Arch = go env GOARCH
Build-Binary -OS $OS -Arch $Arch -Component "client"
}
"android" {
Build-Android
}
"clean" {
Clean-Build
}
{ $_ -in "help", "--help", "-h", "/?" } {
{ $_ -in @("help", "--help", "-h", "/?") } {
Show-Help
return
}
@@ -286,7 +328,6 @@ function Main {
Write-Info ""
Write-Info "Done!"
} finally {
Pop-Location
}

View File

@@ -1,27 +1,22 @@
#!/bin/bash
set -e
set -euo pipefail
# 项目根目录
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="$ROOT_DIR/build"
export GOCACHE="${GOCACHE:-$BUILD_DIR/.gocache}"
# 版本信息
VERSION="${VERSION:-dev}"
BUILD_TIME=$(date -u '+%Y-%m-%d %H:%M:%S')
GIT_COMMIT=$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")
# 默认目标平台
DEFAULT_PLATFORMS="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64"
# 是否启用 UPX 压缩
BUILD_TIME="$(date -u '+%Y-%m-%d %H:%M:%S')"
GIT_COMMIT="$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)"
USE_UPX="${USE_UPX:-true}"
# 颜色输出
DESKTOP_PLATFORMS="linux/amd64 linux/arm64 windows/amd64 windows/arm64 darwin/amd64 darwin/arm64"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
NC='\033[0m'
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
@@ -35,17 +30,14 @@ log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 检查 UPX 是否可用
check_upx() {
if command -v upx &> /dev/null; then
return 0
fi
return 1
command -v upx >/dev/null 2>&1
}
# UPX 压缩二进制
compress_binary() {
local file=$1
local os=$2
if [ "$USE_UPX" != "true" ]; then
return
fi
@@ -53,27 +45,27 @@ compress_binary() {
log_warn "UPX not found, skipping compression"
return
fi
# macOS 二进制不支持 UPX
if [[ "$file" == *"darwin"* ]]; then
if [ "$os" = "darwin" ]; then
log_warn "Skipping UPX for macOS binary: $file"
return
fi
log_info "Compressing $file with UPX..."
upx -9 -q "$file" 2>/dev/null || log_warn "UPX compression failed for $file"
}
# 构建 Web UI
build_web() {
log_info "Building web UI..."
cd "$ROOT_DIR/web"
pushd "$ROOT_DIR/web" >/dev/null
if [ ! -d "node_modules" ]; then
log_info "Installing npm dependencies..."
npm install
fi
npm run build
cd "$ROOT_DIR"
# 复制到 embed 目录
popd >/dev/null
log_info "Copying dist to embed directory..."
rm -rf "$ROOT_DIR/internal/server/app/dist"
cp -r "$ROOT_DIR/web/dist" "$ROOT_DIR/internal/server/app/dist"
@@ -81,85 +73,127 @@ build_web() {
log_info "Web UI built successfully"
}
# 构建单个二进制
output_name() {
local component=$1
local os=$2
if [ "$os" = "windows" ]; then
echo "${component}.exe"
else
echo "${component}"
fi
}
build_binary() {
local os=$1
local arch=$2
local component=$3 # server 或 client
local output_name="${component}"
if [ "$os" = "windows" ]; then
output_name="${component}.exe"
fi
local component=$3
local output_dir="$BUILD_DIR/${os}_${arch}"
mkdir -p "$output_dir"
local output_file
output_file="$(output_name "$component" "$os")"
local output_path="$output_dir/$output_file"
mkdir -p "$output_dir"
log_info "Building $component for $os/$arch..."
GOOS=$os GOARCH=$arch go build \
GOOS="$os" GOARCH="$arch" CGO_ENABLED=0 go build \
-buildvcs=false \
-trimpath \
-ldflags "-s -w -X 'main.Version=$VERSION' -X 'main.BuildTime=$BUILD_TIME' -X 'main.GitCommit=$GIT_COMMIT'" \
-o "$output_dir/$output_name" \
-o "$output_path" \
"$ROOT_DIR/cmd/$component"
# UPX 压缩
compress_binary "$output_dir/$output_name"
compress_binary "$output_path" "$os"
log_info " -> $output_path"
}
# 构建所有平台
build_all() {
local platforms="${1:-$DEFAULT_PLATFORMS}"
local platforms="${1:-$DESKTOP_PLATFORMS}"
local platform os arch
for platform in $platforms; do
local os="${platform%/*}"
local arch="${platform#*/}"
build_binary "$os" "$arch" "server"
build_binary "$os" "$arch" "client"
os="${platform%/*}"
arch="${platform#*/}"
build_binary "$os" "$arch" server
build_binary "$os" "$arch" client
done
}
# 仅构建当前平台
build_current() {
local os=$(go env GOOS)
local arch=$(go env GOARCH)
local os
local arch
build_binary "$os" "$arch" "server"
build_binary "$os" "$arch" "client"
os="$(go env GOOS)"
arch="$(go env GOARCH)"
build_binary "$os" "$arch" server
build_binary "$os" "$arch" client
log_info "Binaries built in $BUILD_DIR/${os}_${arch}/"
}
# 清理构建产物
build_android() {
local output_dir="$BUILD_DIR/android_arm64"
mkdir -p "$output_dir"
log_info "Building client for android/arm64..."
GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build \
-buildvcs=false \
-trimpath \
-ldflags "-s -w -X 'main.Version=$VERSION' -X 'main.BuildTime=$BUILD_TIME' -X 'main.GitCommit=$GIT_COMMIT'" \
-o "$output_dir/client" \
"$ROOT_DIR/cmd/client"
if command -v gomobile >/dev/null 2>&1; then
log_info "Building gomobile Android binding..."
gomobile bind -target=android/arm64 -o "$output_dir/gotunnelmobile.aar" github.com/gotunnel/mobile/gotunnelmobile
else
log_warn "gomobile not found, skipping Android AAR build"
fi
if [ -d "$ROOT_DIR/android" ]; then
if [ -x "$ROOT_DIR/android/gradlew" ]; then
log_info "Building Android debug APK..."
(cd "$ROOT_DIR/android" && ./gradlew assembleDebug)
else
log_warn "android/gradlew not found, skipping APK build"
fi
else
log_warn "Android host project not found, skipping APK build"
fi
}
clean() {
log_info "Cleaning build directory..."
rm -rf "$BUILD_DIR"
log_info "Clean completed"
}
# 显示帮助
show_help() {
echo "Usage: $0 [command] [options]"
echo ""
echo "Commands:"
echo " all Build for all platforms (default: $DEFAULT_PLATFORMS)"
echo " current Build for current platform only"
echo " web Build web UI only"
echo " server Build server for current platform"
echo " client Build client for current platform"
echo " clean Clean build directory"
echo " help Show this help message"
echo ""
echo "Environment variables:"
echo " VERSION Set version string (default: dev)"
echo " USE_UPX Enable UPX compression (default: true)"
echo ""
echo "Examples:"
echo " $0 current # Build for current platform"
echo " $0 all # Build for all platforms"
echo " VERSION=1.0.0 $0 all # Build with version"
cat <<'EOF'
Usage: build.sh [command] [options]
Commands:
all Build web UI + all desktop platforms (default)
current Build web UI + current platform only
web Build web UI only
server Build server for current platform
client Build client for current platform
android Build android/arm64 client and optional Android artifacts
clean Clean build directory
help Show this help message
Environment variables:
VERSION Set version string (default: dev)
USE_UPX Enable UPX compression (default: true)
Examples:
./scripts/build.sh current
VERSION=1.0.0 ./scripts/build.sh all
EOF
}
# 主函数
main() {
cd "$ROOT_DIR"
@@ -176,10 +210,13 @@ main() {
build_web
;;
server)
build_binary "$(go env GOOS)" "$(go env GOARCH)" "server"
build_binary "$(go env GOOS)" "$(go env GOARCH)" server
;;
client)
build_binary "$(go env GOOS)" "$(go env GOARCH)" "client"
build_binary "$(go env GOOS)" "$(go env GOARCH)" client
;;
android)
build_android
;;
clean)
clean

View File

@@ -1,3 +0,0 @@
server: "127.0.0.1:7000"
token: "testtoken"
id: "testclient"

7
web/package-lock.json generated
View File

@@ -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",

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { get, post, put, del, getToken } from '../config/axios'
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo, PluginConfigResponse, JSPlugin, RuleSchemasMap, LogEntry, LogStreamOptions, ConfigField } from '../types'
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, LogEntry, LogStreamOptions, InstallCommandResponse } from '../types'
// 重新导出 token 管理方法
export { getToken, setToken, removeToken } from '../config/axios'
@@ -24,91 +24,6 @@ export const reloadConfig = () => post('/config/reload')
export const pushConfigToClient = (id: string) => post(`/client/${id}/push`)
export const disconnectClient = (id: string) => post(`/client/${id}/disconnect`)
export const restartClient = (id: string) => post(`/client/${id}/restart`)
export const installPluginsToClient = (id: string, plugins: string[]) =>
post(`/client/${id}/install-plugins`, { plugins })
// 规则配置模式
export const getRuleSchemas = () => get<RuleSchemasMap>('/rule-schemas')
// 客户端插件控制(使用 pluginID
export const startClientPlugin = (clientId: string, pluginId: string, ruleName: string) =>
post(`/client/${clientId}/plugin/${pluginId}/start`, { rule_name: ruleName })
export const stopClientPlugin = (clientId: string, pluginId: string, ruleName: string) =>
post(`/client/${clientId}/plugin/${pluginId}/stop`, { rule_name: ruleName })
export const restartClientPlugin = (clientId: string, pluginId: string, ruleName: string) =>
post(`/client/${clientId}/plugin/${pluginId}/restart`, { rule_name: ruleName })
export const deleteClientPlugin = (clientId: string, pluginId: string) =>
post(`/client/${clientId}/plugin/${pluginId}/delete`)
export const updateClientPluginConfigWithRestart = (clientId: string, pluginId: string, ruleName: string, config: Record<string, string>, restart: boolean) =>
post(`/client/${clientId}/plugin/${pluginId}/config`, { rule_name: ruleName, config, restart })
// 插件管理
export const getPlugins = () => get<PluginInfo[]>('/plugins')
export const enablePlugin = (name: string) => post(`/plugin/${name}/enable`)
export const disablePlugin = (name: string) => post(`/plugin/${name}/disable`)
// 扩展商店
export const getStorePlugins = () => get<{ plugins: StorePluginInfo[] }>('/store/plugins')
export const installStorePlugin = (
pluginName: string,
downloadUrl: string,
signatureUrl: string,
clientId: string,
remotePort?: number,
version?: string,
configSchema?: ConfigField[],
authEnabled?: boolean,
authUsername?: string,
authPassword?: string
) =>
post('/store/install', {
plugin_name: pluginName,
version: version || '',
download_url: downloadUrl,
signature_url: signatureUrl,
client_id: clientId,
remote_port: remotePort || 0,
config_schema: configSchema || [],
auth_enabled: authEnabled || false,
auth_username: authUsername || '',
auth_password: authPassword || ''
})
// 客户端插件配置
export const getClientPluginConfig = (clientId: string, pluginName: string) =>
get<PluginConfigResponse>(`/client-plugin/${clientId}/${pluginName}/config`)
export const updateClientPluginConfig = (clientId: string, pluginName: string, config: Record<string, string>) =>
put(`/client-plugin/${clientId}/${pluginName}/config`, { config })
// JS 插件管理
export const getJSPlugins = () => get<JSPlugin[]>('/js-plugins')
export const createJSPlugin = (plugin: JSPlugin) => post('/js-plugins', plugin)
export const getJSPlugin = (name: string) => get<JSPlugin>(`/js-plugin/${name}`)
export const updateJSPlugin = (name: string, plugin: JSPlugin) => put(`/js-plugin/${name}`, plugin)
export const deleteJSPlugin = (name: string) => del(`/js-plugin/${name}`)
export const pushJSPluginToClient = (pluginName: string, clientId: string, remotePort?: number) =>
post(`/js-plugin/${pluginName}/push/${clientId}`, { remote_port: remotePort || 0 })
export const updateJSPluginConfig = (name: string, config: Record<string, string>) =>
put(`/js-plugin/${name}/config`, { config })
export const setJSPluginEnabled = (name: string, enabled: boolean) =>
post(`/js-plugin/${name}/${enabled ? 'enable' : 'disable'}`)
// 插件 API 代理(通过 pluginID 调用插件自定义 API
export const callPluginAPI = <T = any>(clientId: string, pluginId: string, method: string, route: string, body?: any) => {
const path = `/client/${clientId}/plugin-api/${pluginId}${route.startsWith('/') ? route : '/' + route}`
switch (method.toUpperCase()) {
case 'GET':
return get<T>(path)
case 'POST':
return post<T>(path, body)
case 'PUT':
return put<T>(path, body)
case 'DELETE':
return del<T>(path)
default:
return get<T>(path)
}
}
// 更新管理
export interface UpdateInfo {
@@ -209,6 +124,28 @@ export interface SystemStats {
export const getClientSystemStats = (clientId: string) => get<SystemStats>(`/client/${clientId}/system-stats`)
// 客户端截图
export interface ScreenshotData {
data: string // Base64 JPEG
width: number
height: number
timestamp: number
error?: string
}
export const getClientScreenshot = (clientId: string, quality?: number) =>
get<ScreenshotData>(`/client/${clientId}/screenshot${quality ? '?quality=' + quality : ''}`)
// Shell 执行
export interface ShellResult {
output: string
exit_code: number
error?: string
}
export const executeClientShell = (clientId: string, command: string, timeout?: number) =>
post<ShellResult>(`/client/${clientId}/shell`, { command, timeout: timeout || 30 })
// 服务器配置
export interface ServerConfigInfo {
bind_addr: string
@@ -225,21 +162,19 @@ export interface WebConfigInfo {
password: string
}
export interface PluginStoreConfigInfo {
url: string
}
export interface ServerConfigResponse {
server: ServerConfigInfo
web: WebConfigInfo
plugin_store: PluginStoreConfigInfo
}
export interface UpdateServerConfigRequest {
server?: Partial<ServerConfigInfo>
web?: Partial<WebConfigInfo>
plugin_store?: Partial<PluginStoreConfigInfo>
}
export const getServerConfig = () => get<ServerConfigResponse>('/config')
export const updateServerConfig = (config: UpdateServerConfigRequest) => put('/config', config)
// 安装命令生成
export const generateInstallCommand = () =>
post<InstallCommandResponse>('/install/generate')

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
const props = defineProps<{
label: string
value: string | number
hint?: string
tone?: 'default' | 'success' | 'warning' | 'info'
}>()
const toneClass = props.tone || 'default'
</script>
<template>
<article class="metric-card" :class="`metric-card--${toneClass}`">
<span class="metric-card__label">{{ label }}</span>
<strong class="metric-card__value">{{ value }}</strong>
<span v-if="hint" class="metric-card__hint">{{ hint }}</span>
</article>
</template>
<style scoped>
.metric-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px;
min-height: 128px;
border-radius: 20px;
background: var(--glass-bg-card);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-card);
}
.metric-card__label {
font-size: 13px;
color: var(--color-text-secondary);
}
.metric-card__value {
font-size: clamp(26px, 4vw, 34px);
line-height: 1;
color: var(--color-text-primary);
letter-spacing: -0.04em;
}
.metric-card__hint {
margin-top: auto;
font-size: 13px;
color: var(--color-text-muted);
}
.metric-card--success {
border-color: rgba(16, 185, 129, 0.24);
}
.metric-card--warning {
border-color: rgba(245, 158, 11, 0.24);
}
.metric-card--info {
border-color: rgba(6, 182, 212, 0.24);
}
</style>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
defineProps<{
title: string
subtitle?: string
eyebrow?: string
}>()
</script>
<template>
<section class="page-shell">
<div class="page-shell__glow page-shell__glow--primary"></div>
<div class="page-shell__glow page-shell__glow--secondary"></div>
<header class="page-shell__header">
<div class="page-shell__heading">
<span v-if="eyebrow" class="page-shell__eyebrow">{{ eyebrow }}</span>
<h1>{{ title }}</h1>
<p v-if="subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.actions" class="page-shell__actions">
<slot name="actions" />
</div>
</header>
<div v-if="$slots.metrics" class="page-shell__metrics">
<slot name="metrics" />
</div>
<div class="page-shell__content">
<slot />
</div>
</section>
</template>
<style scoped>
.page-shell {
position: relative;
padding: 32px;
overflow: hidden;
}
.page-shell__glow {
position: absolute;
border-radius: 999px;
filter: blur(80px);
opacity: 0.18;
pointer-events: none;
}
.page-shell__glow--primary {
width: 320px;
height: 320px;
top: -120px;
right: -80px;
background: var(--color-accent);
}
.page-shell__glow--secondary {
width: 280px;
height: 280px;
bottom: -120px;
left: -40px;
background: #8b5cf6;
}
.page-shell__header,
.page-shell__metrics,
.page-shell__content {
position: relative;
z-index: 1;
}
.page-shell__header {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-start;
margin-bottom: 24px;
}
.page-shell__heading {
max-width: 720px;
}
.page-shell__eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
margin-bottom: 12px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.12);
border: 1px solid rgba(59, 130, 246, 0.18);
color: var(--color-accent);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
}
.page-shell__heading h1 {
margin: 0;
font-size: clamp(28px, 4vw, 40px);
font-weight: 700;
letter-spacing: -0.03em;
color: var(--color-text-primary);
}
.page-shell__heading p {
margin: 10px 0 0;
max-width: 640px;
color: var(--color-text-secondary);
font-size: 15px;
line-height: 1.7;
}
.page-shell__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.page-shell__metrics {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
margin-bottom: 24px;
}
.page-shell__content {
display: flex;
flex-direction: column;
gap: 20px;
}
@media (max-width: 768px) {
.page-shell {
padding: 20px;
}
.page-shell__header {
flex-direction: column;
}
.page-shell__actions {
width: 100%;
justify-content: stretch;
}
.page-shell__actions :deep(*) {
flex: 1;
}
}
</style>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
defineProps<{
title: string
description?: string
}>()
</script>
<template>
<section class="section-card glass-card">
<header class="section-card__header">
<div>
<h2>{{ title }}</h2>
<p v-if="description">{{ description }}</p>
</div>
<div v-if="$slots.header" class="section-card__extra">
<slot name="header" />
</div>
</header>
<div class="section-card__body">
<slot />
</div>
</section>
</template>
<style scoped>
.section-card {
padding: 22px;
}
.section-card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 18px;
}
.section-card__header h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--color-text-primary);
}
.section-card__header p {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: var(--color-text-secondary);
}
.section-card__extra {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.section-card__body {
display: flex;
flex-direction: column;
gap: 16px;
}
@media (max-width: 768px) {
.section-card {
padding: 18px;
}
.section-card__header {
flex-direction: column;
}
.section-card__extra {
width: 100%;
}
}
</style>

View File

@@ -6,43 +6,6 @@ export interface ProxyRule {
remote_port: number
type?: string
enabled?: boolean
plugin_config?: Record<string, string>
plugin_managed?: boolean // 插件管理标记 - 由插件自动创建的规则
}
// 客户端已安装的插件
export interface ClientPlugin {
id: string // 插件实例唯一 ID
name: string
version: string
enabled: boolean
running: boolean
config?: Record<string, string>
remote_port?: number // 远程监听端口
}
// 插件配置字段
export interface ConfigField {
key: string
label: string
type: 'string' | 'number' | 'bool' | 'select' | 'password'
default?: string
required?: boolean
options?: string[]
description?: string
}
// 规则表单模式
export interface RuleSchema {
needs_local_addr: boolean
extra_fields?: ConfigField[]
}
// 插件配置响应
export interface PluginConfigResponse {
plugin_name: string
schema: ConfigField[]
config: Record<string, string>
}
// 客户端配置
@@ -50,7 +13,6 @@ export interface ClientConfig {
id: string
nickname?: string
rules: ProxyRule[]
plugins?: ClientPlugin[]
}
// 客户端状态
@@ -70,7 +32,6 @@ export interface ClientDetail {
id: string
nickname?: string
rules: ProxyRule[]
plugins?: ClientPlugin[]
online: boolean
last_ping?: string
remote_addr?: string
@@ -88,64 +49,12 @@ export interface ServerStatus {
client_count: number
}
// 插件类型
export const PluginType = {
Proxy: 'proxy',
App: 'app',
Service: 'service',
Tool: 'tool'
} as const
export type PluginTypeValue = typeof PluginType[keyof typeof PluginType]
// 插件信息
export interface PluginInfo {
name: string
version: string
type: string
description: string
source: string
icon?: string
enabled: boolean
rule_schema?: RuleSchema
}
// 扩展商店插件信息
export interface StorePluginInfo {
name: string
version: string
type: string
description: string
author: string
icon?: string
download_url?: string
signature_url?: string
config_schema?: ConfigField[]
}
// JS 插件信息
export interface JSPlugin {
name: string
source: string
signature?: string
description: string
author: string
version?: string
auto_push: string[]
config: Record<string, string>
auto_start: boolean
enabled: boolean
}
// 规则配置模式集合
export type RuleSchemasMap = Record<string, RuleSchema>
// 日志条目
export interface LogEntry {
ts: number // Unix 时间戳 (毫秒)
level: string // 日志级别: debug, info, warn, error
msg: string // 日志消息
src: string // 来源: client, plugin:<name>
src: string // 来源: client
}
// 日志流选项
@@ -154,3 +63,10 @@ export interface LogStreamOptions {
follow?: boolean // 是否持续推送
level?: string // 日志级别过滤
}
// 安装命令响应
export interface InstallCommandResponse {
token: string
expires_at: number
tunnel_port: number
}

View File

@@ -3,8 +3,8 @@ import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ArrowBackOutline, CreateOutline, TrashOutline,
PushOutline, AddOutline, StorefrontOutline, DocumentTextOutline,
ExtensionPuzzleOutline, SettingsOutline, RefreshOutline
PushOutline, AddOutline, RefreshOutline,
PlayOutline
} from '@vicons/ionicons5'
import GlassModal from '../components/GlassModal.vue'
import GlassTag from '../components/GlassTag.vue'
@@ -13,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,
type UpdateInfo, type SystemStats
getClientScreenshot, executeClientShell,
type UpdateInfo, type SystemStats, type ScreenshotData
} from '../api'
import type { ProxyRule, ClientPlugin, ConfigField, StorePluginInfo, RuleSchemasMap } from '../types'
import LogViewer from '../components/LogViewer.vue'
import type { ProxyRule } from '../types'
import InlineLogPanel from '../components/InlineLogPanel.vue'
const route = useRoute()
@@ -34,7 +32,6 @@ const lastPing = ref('')
const remoteAddr = ref('')
const nickname = ref('')
const rules = ref<ProxyRule[]>([])
const clientPlugins = ref<ClientPlugin[]>([])
const loading = ref(false)
const clientOs = ref('')
const clientArch = ref('')
@@ -46,19 +43,23 @@ const updatingClient = ref(false)
const serverVersion = ref('')
// 系统状态相关
const systemStats = ref<SystemStats | null>(null)
const loadingStats = ref(false)
// 系统状态相关
const systemStats = ref<SystemStats | null>(null)
const loadingStats = ref(false)
// Rule Schemas
const pluginRuleSchemas = ref<RuleSchemasMap>({})
const loadRuleSchemas = async () => {
try {
const { data } = await getRuleSchemas()
pluginRuleSchemas.value = data || {}
} catch (e) {
console.error('Failed to load rule schemas', e)
}
}
// 截图相关
const screenshotData = ref<ScreenshotData | null>(null)
const loadingScreenshot = ref(false)
const autoRefreshScreenshot = ref(false)
const screenshotInterval = ref(5) // 默认 5s
const screenshotTimer = ref<number | null>(null)
// Shell 相关
const shellCommand = ref('')
const shellOutput = ref('')
const executingShell = ref(false)
const shellHistory = ref<string[]>([])
const historyIndex = ref(-1)
// Built-in Types (Added WebSocket)
const builtinTypes = [
@@ -80,20 +81,13 @@ const defaultRule = {
local_port: 80,
remote_port: 0,
type: 'tcp',
enabled: true,
plugin_config: {} as Record<string, string>
enabled: true
}
const ruleForm = ref<ProxyRule>({ ...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
}
// 加载服务端版本
@@ -161,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 || ''
@@ -203,6 +196,90 @@ const loadSystemStats = async () => {
}
}
// 截图相关方法
const loadScreenshot = async () => {
if (!online.value) return
loadingScreenshot.value = true
try {
const { data } = await getClientScreenshot(clientId, 70) // 默认质量 70
screenshotData.value = data
} catch (e: any) {
message.error(e.response?.data?.message || '获取截图失败')
} finally {
loadingScreenshot.value = false
}
}
const toggleAutoRefresh = () => {
if (autoRefreshScreenshot.value) {
// 开启自动刷新
loadScreenshot()
screenshotTimer.value = window.setInterval(loadScreenshot, screenshotInterval.value * 1000)
} else {
// 关闭自动刷新
if (screenshotTimer.value) {
clearInterval(screenshotTimer.value)
screenshotTimer.value = null
}
}
}
// Shell 相关方法
const executeShell = async () => {
if (!shellCommand.value.trim()) return
const cmd = shellCommand.value.trim()
shellCommand.value = ''
executingShell.value = true
// 添加到历史记录
shellHistory.value.unshift(cmd)
if (shellHistory.value.length > 50) shellHistory.value.pop()
historyIndex.value = -1
shellOutput.value += `\n> ${cmd}\n`
try {
const { data } = await executeClientShell(clientId, cmd)
if (data.error) {
shellOutput.value += `Error: ${data.error}\n`
} else {
shellOutput.value += data.output + '\n'
}
if (data.exit_code !== 0) {
shellOutput.value += `Exit Code: ${data.exit_code}\n`
}
} catch (e: any) {
shellOutput.value += `Error: ${e.message}\n`
} finally {
executingShell.value = false
// 滚动到底部 (需要 nextTick 和 ref)
setTimeout(() => {
const textarea = document.getElementById('shell-output')
if (textarea) textarea.scrollTop = textarea.scrollHeight
}, 100)
}
}
const handleShellHistory = (direction: 'up' | 'down') => {
if (shellHistory.value.length === 0) return
if (direction === 'up') {
if (historyIndex.value < shellHistory.value.length - 1) {
historyIndex.value++
shellCommand.value = shellHistory.value[historyIndex.value] || ''
}
} else {
if (historyIndex.value > 0) {
historyIndex.value--
shellCommand.value = shellHistory.value[historyIndex.value] || ''
} else if (historyIndex.value === 0) {
historyIndex.value = -1
shellCommand.value = ''
}
}
}
// 格式化字节大小
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'
@@ -268,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
@@ -316,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
@@ -344,126 +420,6 @@ const handleRuleSubmit = async () => {
showRuleModal.value = false
}
// Store & Plugin Logic
const showStoreModal = ref(false)
const storePlugins = ref<StorePluginInfo[]>([])
const storeLoading = ref(false)
const storeInstalling = ref<string | null>(null)
const showInstallConfigModal = ref(false)
const installPlugin = ref<StorePluginInfo | null>(null)
const installRemotePort = ref<number | null>(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<ConfigField[]>([])
const configValues = ref<Record<string, string>>({})
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({
@@ -497,7 +453,6 @@ const handleRestartClient = () => {
const pollTimer = ref<number | null>(null)
onMounted(() => {
loadRuleSchemas()
loadServerVersion()
loadClient()
// 启动自动轮询,每 5 秒刷新一次
@@ -511,42 +466,11 @@ onUnmounted(() => {
clearInterval(pollTimer.value)
pollTimer.value = null
}
if (screenshotTimer.value) {
clearInterval(screenshotTimer.value)
screenshotTimer.value = null
}
})
// Log Viewer
const showLogViewer = ref(false)
// 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()
}
})
}
</script>
<template>
@@ -578,10 +502,6 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<PushOutline class="btn-icon" />
<span>推送配置</span>
</button>
<button class="glass-btn" @click="showLogViewer=true">
<DocumentTextOutline class="btn-icon" />
<span>日志</span>
</button>
<button class="glass-btn danger" @click="confirmDelete">
<TrashOutline class="btn-icon" />
<span>删除</span>
@@ -644,10 +564,6 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<span class="mini-stat-value">{{ rules.length }}</span>
<span class="mini-stat-label">规则数</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value">{{ clientPlugins.length }}</span>
<span class="mini-stat-label">插件数</span>
</div>
</div>
</div>
@@ -660,11 +576,12 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
刷新
</button>
</div>
<div class="card-body">
<div v-if="!systemStats" class="empty-hint">
{{ loadingStats ? '加载中...' : '点击刷新获取状态' }}
</div>
<template v-else>
<div class="card-body system-stats-body">
<Transition name="fade-slide" mode="out-in">
<div v-if="!systemStats" class="empty-hint" key="empty">
{{ loadingStats ? '加载中...' : '点击刷新获取状态' }}
</div>
<div v-else class="system-stats-content" key="stats">
<div class="system-stat-item">
<span class="system-stat-label">CPU</span>
<div class="progress-bar">
@@ -692,7 +609,64 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<div class="system-stat-detail">
{{ formatBytes(systemStats.disk_used) }} / {{ formatBytes(systemStats.disk_total) }}
</div>
</template>
</div>
</Transition>
</div>
</div>
<!-- Screenshot Card -->
<div class="glass-card" v-if="online">
<div class="card-header">
<h3>屏幕截图</h3>
<div class="header-controls">
<GlassSwitch :model-value="autoRefreshScreenshot" @update:model-value="(v: boolean) => { autoRefreshScreenshot = v; toggleAutoRefresh() }" size="small">
自动刷新
</GlassSwitch>
<button class="glass-btn tiny" :disabled="loadingScreenshot" @click="loadScreenshot">
<RefreshOutline class="btn-icon-sm" />
</button>
</div>
</div>
<div class="card-body screenshot-body">
<div class="screenshot-container" v-if="screenshotData">
<img :src="`data:image/jpeg;base64,${screenshotData.data}`" alt="Screenshot" class="screenshot-img" />
<div class="screenshot-meta">
{{ new Date(screenshotData.timestamp).toLocaleTimeString() }} ({{ screenshotData.width }}x{{ screenshotData.height }})
</div>
</div>
<div v-else class="empty-hint" @click="loadScreenshot">
{{ loadingScreenshot ? '截图中...' : '点击获取截图' }}
</div>
</div>
</div>
<!-- Shell Terminal Card -->
<div class="glass-card" v-if="online">
<div class="card-header">
<h3>远程 Shell</h3>
</div>
<div class="card-body shell-body">
<textarea
id="shell-output"
class="shell-output"
readonly
v-model="shellOutput"
></textarea>
<div class="shell-input-group">
<input
type="text"
class="glass-input shell-input"
v-model="shellCommand"
@keydown.enter="executeShell"
@keydown.up.prevent="handleShellHistory('up')"
@keydown.down.prevent="handleShellHistory('down')"
placeholder="输入命令..."
:disabled="executingShell"
/>
<button class="glass-btn primary small" :disabled="executingShell" @click="executeShell">
<PlayOutline class="btn-icon-sm" />
</button>
</div>
</div>
</div>
@@ -725,7 +699,7 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<span class="rule-name">{{ rule.name }}</span>
<span><GlassTag :type="rule.type==='websocket'?'info':'default'">{{ (rule.type || 'tcp').toUpperCase() }}</GlassTag></span>
<span class="rule-mapping">
{{ needsLocalAddr(rule.type||'tcp') ? `${rule.local_ip}:${rule.local_port}` : '-' }}
{{ needsLocalAddr() ? `${rule.local_ip}:${rule.local_port}` : '-' }}
:{{ rule.remote_port }}
</span>
@@ -733,64 +707,14 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<GlassSwitch :model-value="rule.enabled !== false" @update:model-value="(v: boolean) => { rule.enabled = v; saveRules(rules) }" size="small" />
</span>
<span class="rule-actions">
<GlassTag v-if="rule.plugin_managed" type="info" title="此规则由插件管理">插件托管</GlassTag>
<template v-else>
<button class="icon-btn" @click="openEditRule(rule)">编辑</button>
<button class="icon-btn danger" @click="handleDeleteRule(rule)">删除</button>
</template>
<button class="icon-btn" @click="openEditRule(rule)">编辑</button>
<button class="icon-btn danger" @click="handleDeleteRule(rule)">删除</button>
</span>
</div>
</div>
</div>
</div>
<!-- Plugins Card -->
<div class="glass-card">
<div class="card-header">
<h3>已安装扩展</h3>
<button class="glass-btn small" @click="openStoreModal">
<StorefrontOutline class="btn-icon" />
插件商店
</button>
</div>
<div class="card-body">
<div v-if="clientPlugins.length === 0" class="empty-state">
<p>暂无安装的扩展</p>
</div>
<div v-else class="plugins-list">
<div v-for="plugin in clientPlugins" :key="plugin.id" class="plugin-item">
<div class="plugin-info">
<ExtensionPuzzleOutline class="plugin-icon" />
<span class="plugin-name">{{ plugin.name }}</span>
<span class="plugin-version">v{{ plugin.version }}</span>
</div>
<div class="plugin-meta">
<span>端口: {{ plugin.remote_port || '-' }}</span>
<GlassTag :type="plugin.running ? 'success' : 'default'" round>
{{ plugin.running ? '运行中' : '已停止' }}
</GlassTag>
<GlassSwitch :model-value="plugin.enabled" size="small" @update:model-value="toggleClientPlugin(plugin)" />
</div>
<div class="plugin-actions">
<button v-if="plugin.running && plugin.remote_port" class="icon-btn success" @click="handleOpenPlugin(plugin)">打开</button>
<button v-if="!plugin.running" class="icon-btn" @click="handleStartPlugin(plugin)" :disabled="!online || !plugin.enabled">启动</button>
<div class="dropdown-wrapper">
<button class="icon-btn" @click="togglePluginMenu(plugin.id)">
<SettingsOutline class="settings-icon" />
</button>
<div v-if="activePluginMenu === plugin.id" class="dropdown-menu">
<button @click="handleRestartPlugin(plugin); activePluginMenu = ''" :disabled="!plugin.running">重启</button>
<button @click="openConfigModal(plugin); activePluginMenu = ''">配置</button>
<button @click="handleStopPlugin(plugin); activePluginMenu = ''" :disabled="!plugin.running">停止</button>
<button class="danger" @click="handleDeletePlugin(plugin); activePluginMenu = ''">删除</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Inline Log Panel -->
<div class="glass-card">
<InlineLogPanel :client-id="clientId" />
@@ -811,7 +735,7 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<option v-for="t in builtinTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</div>
<template v-if="needsLocalAddr(ruleForm.type || 'tcp')">
<template v-if="needsLocalAddr()">
<div class="form-group">
<label class="form-label">本地IP</label>
<input v-model="ruleForm.local_ip" class="form-input" placeholder="127.0.0.1" />
@@ -825,43 +749,12 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<label class="form-label">远程端口</label>
<input v-model.number="ruleForm.remote_port" type="number" class="form-input" min="1" max="65535" />
</div>
<template v-for="field in getExtraFields(ruleForm.type || '')" :key="field.key">
<div class="form-group">
<label class="form-label">{{ field.label }}</label>
<input v-if="field.type==='string'" v-model="ruleForm.plugin_config![field.key]" class="form-input" />
<input v-if="field.type==='password'" type="password" v-model="ruleForm.plugin_config![field.key]" class="form-input" />
<label v-if="field.type==='bool'" class="form-toggle">
<input type="checkbox" :checked="ruleForm.plugin_config![field.key]==='true'" @change="(e: Event) => ruleForm.plugin_config![field.key] = String((e.target as HTMLInputElement).checked)" />
<span>启用</span>
</label>
</div>
</template>
<template #footer>
<button class="glass-btn" @click="showRuleModal = false">取消</button>
<button class="glass-btn primary" @click="handleRuleSubmit">保存</button>
</template>
</GlassModal>
<!-- Config Modal -->
<GlassModal :show="showConfigModal" :title="`${configPluginName} 配置`" @close="showConfigModal = false">
<div v-if="configLoading" class="loading-state">加载中...</div>
<template v-else>
<div v-for="field in configSchema" :key="field.key" class="form-group">
<label class="form-label">{{ field.label }}</label>
<input v-if="field.type==='string'" v-model="configValues[field.key]" class="form-input" />
<input v-if="field.type==='password'" type="password" v-model="configValues[field.key]" class="form-input" />
<input v-if="field.type==='number'" type="number" :value="Number(configValues[field.key])" @input="(e: Event) => configValues[field.key] = (e.target as HTMLInputElement).value" class="form-input" />
<label v-if="field.type==='bool'" class="form-toggle">
<input type="checkbox" :checked="configValues[field.key]==='true'" @change="(e: Event) => configValues[field.key] = String((e.target as HTMLInputElement).checked)" />
<span>启用</span>
</label>
</div>
</template>
<template #footer>
<button class="glass-btn" @click="showConfigModal = false">取消</button>
<button class="glass-btn primary" @click="savePluginConfig">保存</button>
</template>
</GlassModal>
<!-- Rename Modal -->
<GlassModal :show="showRenameModal" title="重命名客户端" width="400px" @close="showRenameModal = false">
@@ -874,37 +767,6 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<button class="glass-btn primary" @click="saveRename">保存</button>
</template>
</GlassModal>
<!-- Store Modal -->
<GlassModal :show="showStoreModal" title="插件商店" width="600px" @close="showStoreModal = false">
<div v-if="storeLoading" class="loading-state">加载中...</div>
<div v-else class="store-grid">
<div v-for="plugin in storePlugins" :key="plugin.name" class="store-plugin-card">
<div class="store-plugin-header">
<span class="store-plugin-name">{{ plugin.name }}</span>
<GlassTag>v{{ plugin.version }}</GlassTag>
</div>
<p class="store-plugin-desc">{{ plugin.description }}</p>
<button class="glass-btn primary small full" @click="handleInstallStorePlugin(plugin)">
安装
</button>
</div>
</div>
</GlassModal>
<!-- Install Config Modal -->
<GlassModal :show="showInstallConfigModal" title="安装配置" width="400px" @close="showInstallConfigModal = false">
<div class="form-group">
<label class="form-label">远程端口</label>
<input v-model.number="installRemotePort" type="number" class="form-input" min="1" max="65535" />
</div>
<template #footer>
<button class="glass-btn" @click="showInstallConfigModal = false">取消</button>
<button class="glass-btn primary" @click="confirmInstallPlugin">确认安装</button>
</template>
</GlassModal>
<LogViewer :visible="showLogViewer" @close="showLogViewer = false" :client-id="clientId" />
</div>
</template>
@@ -917,9 +779,54 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
padding: 32px;
}
/* Hide particles */
/* Particles */
.particles {
display: none;
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.particle {
position: absolute;
border-radius: 50%;
opacity: 0.15;
filter: blur(60px);
animation: float 20s ease-in-out infinite;
}
.particle-1 {
width: 350px;
height: 350px;
background: var(--color-accent);
top: -80px;
right: -80px;
}
.particle-2 {
width: 280px;
height: 280px;
background: #8b5cf6;
bottom: -40px;
left: -40px;
animation-delay: -5s;
}
.particle-3 {
width: 220px;
height: 220px;
background: var(--color-success);
top: 40%;
left: 30%;
animation-delay: -10s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(30px, -30px) scale(1.05); }
50% { transform: translate(-20px, 20px) scale(0.95); }
75% { transform: translate(-30px, -20px) scale(1.02); }
}
.client-content {
@@ -1042,6 +949,7 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
display: grid;
grid-template-columns: 300px 1fr;
gap: 24px;
align-items: start;
}
@media (max-width: 900px) {
@@ -1286,92 +1194,6 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
background: rgba(0, 186, 124, 0.15);
}
/* Plugins List */
.plugins-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.plugin-item {
background: var(--color-bg-elevated);
border-radius: 10px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.plugin-info {
display: flex;
align-items: center;
gap: 10px;
}
.plugin-name {
font-weight: 600;
color: var(--color-text-primary);
font-size: 14px;
}
.plugin-version {
font-size: 12px;
color: var(--color-text-muted);
}
.plugin-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--color-text-secondary);
}
.plugin-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
/* Store Plugin Card */
.store-plugin-card {
background: var(--color-bg-elevated);
border-radius: 10px;
padding: 16px;
border: 1px solid var(--color-border-light);
}
.store-plugin-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.store-plugin-name {
font-weight: 600;
color: var(--color-text-primary);
font-size: 14px;
}
.store-plugin-desc {
color: var(--color-text-secondary);
font-size: 12px;
margin: 0 0 12px 0;
line-height: 1.5;
}
/* Store Grid */
.store-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
@media (max-width: 500px) {
.store-grid { grid-template-columns: 1fr; }
}
/* Form Styles */
.form-group {
margin-bottom: 16px;
@@ -1514,17 +1336,120 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
height: 14px;
}
.plugin-icon {
width: 18px;
height: 18px;
color: var(--color-accent);
}
.settings-icon {
width: 16px;
height: 16px;
}
/* System Stats Transition */
.system-stats-body {
overflow: hidden;
}
.system-stats-content {
display: flex;
flex-direction: column;
}
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s ease;
}
.fade-slide-enter-from,
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-10px);
}
/* Screenshot Card */
.screenshot-body {
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
background: rgba(0, 0, 0, 0.2);
border-radius: 0 0 16px 16px;
overflow: hidden;
position: relative;
}
.screenshot-container {
width: 100%;
height: 100%;
position: relative;
}
.screenshot-img {
width: 100%;
height: auto;
display: block;
}
.screenshot-meta {
position: absolute;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, 0.6);
color: #fff;
padding: 4px 8px;
font-size: 12px;
border-top-left-radius: 8px;
font-family: monospace;
}
/* Shell Terminal Card */
.shell-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.shell-output {
width: 100%;
height: 300px;
background: #1a1a1a;
color: #0f0;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
padding: 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
resize: vertical;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.shell-input-group {
display: flex;
gap: 8px;
}
.shell-input {
flex: 1;
font-family: 'Consolas', 'Monaco', monospace;
}
.header-controls {
display: flex;
align-items: center;
gap: 12px;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(10px);
}
/* System Stats */
.system-stat-item {
display: flex;

View File

@@ -1,430 +1,323 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getClients } from '../api'
import type { ClientStatus } from '../types'
import GlassModal from '../components/GlassModal.vue'
import MetricCard from '../components/MetricCard.vue'
import PageShell from '../components/PageShell.vue'
import SectionCard from '../components/SectionCard.vue'
import { generateInstallCommand, getClients } from '../api'
import { useToast } from '../composables/useToast'
import type { ClientStatus, InstallCommandResponse } from '../types'
const router = useRouter()
const message = useToast()
const clients = ref<ClientStatus[]>([])
const loading = ref(true)
const showInstallModal = ref(false)
const installData = ref<InstallCommandResponse | null>(null)
const generatingInstall = ref(false)
const search = ref('')
const quoteShellArg = (value: string) => `'${value.replace(/'/g, `'\"'\"'`)}'`
const quotePowerShellSingle = (value: string) => value.replace(/'/g, "''")
const resolveTunnelHost = () => window.location.hostname || 'localhost'
const resolveWebBaseUrl = () => window.location.origin || 'http://localhost:7500'
const formatServerAddr = (host: string, port: number) => {
const normalizedHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
return `${normalizedHost}:${port}`
}
const buildInstallCommands = (data: InstallCommandResponse) => {
const webBaseUrl = resolveWebBaseUrl()
const serverAddr = formatServerAddr(resolveTunnelHost(), data.tunnel_port)
const installScriptUrl = `${webBaseUrl}/install.sh`
const installPs1Url = `${webBaseUrl}/install.ps1`
const psServerAddr = quotePowerShellSingle(serverAddr)
const psToken = quotePowerShellSingle(data.token)
const psBaseUrl = quotePowerShellSingle(webBaseUrl)
return {
linux: `bash <(curl -fsSL -H "X-GoTunnel-Install-Token: ${data.token}" ${installScriptUrl}) -s ${quoteShellArg(serverAddr)} -t ${quoteShellArg(data.token)} -b ${quoteShellArg(webBaseUrl)}`,
macos: `bash <(curl -fsSL -H "X-GoTunnel-Install-Token: ${data.token}" ${installScriptUrl}) -s ${quoteShellArg(serverAddr)} -t ${quoteShellArg(data.token)} -b ${quoteShellArg(webBaseUrl)}`,
windows: `powershell -c \"irm ${installPs1Url} -Headers @{ 'X-GoTunnel-Install-Token' = '${psToken}' } | iex; Install-GoTunnel -Server '${psServerAddr}' -Token '${psToken}' -BaseUrl '${psBaseUrl}'\"`,
}
}
const loadClients = async () => {
loading.value = true
try {
const { data } = await getClients()
clients.value = data || []
} catch (e) {
console.error('Failed to load clients', e)
} catch (error) {
console.error('Failed to load clients', error)
message.error('客户端列表加载失败')
} finally {
loading.value = false
}
}
const onlineClients = computed(() => clients.value.filter(c => c.online).length)
const viewClient = (id: string) => {
router.push(`/client/${id}`)
const openInstallModal = async () => {
generatingInstall.value = true
try {
const { data } = await generateInstallCommand()
installData.value = data
showInstallModal.value = true
} catch (error) {
console.error('Failed to generate install command', error)
message.error('安装命令生成失败')
} finally {
generatingInstall.value = false
}
}
const copyCommand = async (command: string) => {
try {
await navigator.clipboard.writeText(command)
message.success('命令已复制')
} catch (error) {
console.error('Failed to copy command', error)
message.error('复制失败,请手动复制')
}
}
const filteredClients = computed(() => {
const keyword = search.value.trim().toLowerCase()
if (!keyword) return clients.value
return clients.value.filter((client) => {
return [client.id, client.nickname, client.remote_addr, client.os, client.arch]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword))
})
})
const onlineClients = computed(() => clients.value.filter((client) => client.online).length)
const offlineClients = computed(() => Math.max(clients.value.length - onlineClients.value, 0))
const installCommands = computed(() => (installData.value ? buildInstallCommands(installData.value) : null))
onMounted(loadClients)
</script>
<template>
<div class="clients-page">
<!-- Particles -->
<div class="particles">
<div class="particle particle-1"></div>
<div class="particle particle-2"></div>
<div class="particle particle-3"></div>
</div>
<PageShell title="客户端" eyebrow="Clients" subtitle="统一管理已注册节点、连接状态与快速安装命令,减少操作跳转。">
<template #actions>
<button class="glass-btn" :disabled="generatingInstall" @click="openInstallModal">
{{ generatingInstall ? '生成中...' : '安装命令' }}
</button>
<button class="glass-btn primary" @click="loadClients">{{ loading ? '刷新中...' : '刷新列表' }}</button>
</template>
<div class="clients-content">
<!-- Header -->
<div class="page-header">
<h1 class="page-title">客户端管理</h1>
<p class="page-subtitle">管理所有连接的客户端</p>
</div>
<template #metrics>
<MetricCard label="客户端总数" :value="clients.length" hint="已接入的全部节点" />
<MetricCard label="在线节点" :value="onlineClients" hint="可立即推送配置" tone="success" />
<MetricCard label="离线节点" :value="offlineClients" hint="等待心跳恢复" tone="warning" />
<MetricCard label="当前筛选结果" :value="filteredClients.length" hint="支持 ID / 昵称 / 地址搜索" tone="info" />
</template>
<!-- Stats Row -->
<div class="stats-row">
<div class="stat-card">
<span class="stat-value">{{ clients.length }}</span>
<span class="stat-label">总客户端</span>
</div>
<div class="stat-card">
<span class="stat-value online">{{ onlineClients }}</span>
<span class="stat-label">在线</span>
</div>
<div class="stat-card">
<span class="stat-value offline">{{ clients.length - onlineClients }}</span>
<span class="stat-label">离线</span>
</div>
</div>
<SectionCard title="节点列表" description="使用统一卡片样式展示连接信息,便于快速判断状态与进入详情页。">
<template #header>
<input v-model="search" class="glass-input search-input" type="search" placeholder="搜索 ID / 昵称 / 地址" />
</template>
<!-- Client List -->
<div class="glass-card">
<div class="card-header">
<h3>客户端列表</h3>
<button class="glass-btn small" @click="loadClients">刷新</button>
</div>
<div class="card-body">
<div v-if="loading" class="loading-state">加载中...</div>
<div v-else-if="clients.length === 0" class="empty-state">
<p>暂无客户端连接</p>
<p class="empty-hint">等待客户端连接...</p>
</div>
<div v-else class="clients-grid">
<div
v-for="client in clients"
:key="client.id"
class="client-card"
@click="viewClient(client.id)"
>
<div class="client-header">
<div class="client-status" :class="{ online: client.online }"></div>
<h4 class="client-name">{{ client.nickname || client.id }}</h4>
</div>
<p v-if="client.nickname" class="client-id">{{ client.id }}</p>
<div class="client-info">
<span v-if="client.remote_addr && client.online">{{ client.remote_addr }}</span>
<span>{{ client.rule_count || 0 }} 条规则</span>
</div>
<div class="client-tag" :class="client.online ? 'online' : 'offline'">
{{ client.online ? '在线' : '离线' }}
</div>
<!-- Heartbeat indicator -->
<div class="heartbeat-indicator" :class="{ online: client.online, offline: !client.online }">
<span class="heartbeat-dot"></span>
<div v-if="loading" class="empty-state">正在加载客户端列表...</div>
<div v-else-if="filteredClients.length === 0" class="empty-state">未找到匹配的客户端</div>
<div v-else class="client-grid">
<article v-for="client in filteredClients" :key="client.id" class="client-card" @click="router.push(`/client/${client.id}`)">
<div class="client-card__header">
<div>
<div class="client-card__title">
<span class="status-dot" :class="{ online: client.online }"></span>
<strong>{{ client.nickname || client.id }}</strong>
</div>
<p>{{ client.nickname ? client.id : client.remote_addr || '等待首次连接' }}</p>
</div>
<span class="state-pill" :class="client.online ? 'online' : 'offline'">{{ client.online ? '在线' : '离线' }}</span>
</div>
</div>
<dl class="client-card__meta">
<div>
<dt>地址</dt>
<dd>{{ client.remote_addr || '未上报' }}</dd>
</div>
<div>
<dt>规则数</dt>
<dd>{{ client.rule_count || 0 }}</dd>
</div>
<div>
<dt>平台</dt>
<dd>{{ [client.os, client.arch].filter(Boolean).join(' / ') || '未知' }}</dd>
</div>
</dl>
</article>
</div>
</div>
</div>
</SectionCard>
<GlassModal :show="showInstallModal" title="安装命令" width="760px" @close="showInstallModal = false">
<div v-if="installCommands" class="install-grid">
<article v-for="item in [
{ label: 'Linux', value: installCommands.linux },
{ label: 'macOS', value: installCommands.macos },
{ label: 'Windows', value: installCommands.windows },
]" :key="item.label" class="install-card">
<header>
<strong>{{ item.label }}</strong>
<button class="glass-btn small" @click="copyCommand(item.value)">复制</button>
</header>
<code>{{ item.value }}</code>
</article>
</div>
<template #footer>
<span class="install-footnote">命令内含一次性 token使用后请重新生成</span>
</template>
</GlassModal>
</PageShell>
</template>
<style scoped>
.clients-page {
min-height: calc(100vh - 116px);
position: relative;
overflow: hidden;
padding: 32px;
.search-input {
min-width: min(320px, 100%);
}
/* 动画背景粒子 */
.particles {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.particle {
position: absolute;
border-radius: 50%;
opacity: 0.15;
filter: blur(60px);
animation: float 20s ease-in-out infinite;
}
.particle-1 {
width: 350px;
height: 350px;
background: var(--color-accent);
top: -80px;
right: -80px;
}
.particle-2 {
width: 280px;
height: 280px;
background: #8b5cf6;
bottom: -40px;
left: -40px;
animation-delay: -5s;
}
.particle-3 {
width: 220px;
height: 220px;
background: var(--color-success);
top: 40%;
left: 30%;
animation-delay: -10s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(30px, -30px) scale(1.05); }
50% { transform: translate(-20px, 20px) scale(0.95); }
75% { transform: translate(-30px, -20px) scale(1.02); }
}
.clients-content {
position: relative;
z-index: 10;
max-width: 1200px;
margin: 0 auto;
}
.page-header { margin-bottom: 24px; }
.page-title {
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 8px 0;
}
.page-subtitle {
color: var(--color-text-secondary);
margin: 0;
font-size: 14px;
}
/* Stats */
.stats-row {
.client-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-radius: 16px;
border: 1px solid var(--color-border);
padding: 20px;
text-align: center;
box-shadow: var(--shadow-card);
transition: all 0.2s ease;
position: relative;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 20%;
right: 20%;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg), var(--shadow-glow);
}
.stat-value {
display: block;
font-size: 32px;
font-weight: 700;
color: var(--color-text-primary);
}
.stat-value.online { color: var(--color-success); }
.stat-value.offline { color: var(--color-text-muted); }
.stat-label {
font-size: 13px;
color: var(--color-text-secondary);
}
/* Glass Card */
.glass-card {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-radius: 16px;
border: 1px solid var(--color-border);
box-shadow: var(--shadow-card);
position: relative;
}
.glass-card::before {
content: '';
position: absolute;
top: 0;
left: 10%;
right: 10%;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%);
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--color-border-light);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
}
.card-body { padding: 20px; }
.loading-state, .empty-state {
text-align: center;
padding: 48px;
color: var(--color-text-muted);
}
.empty-hint {
font-size: 13px;
color: var(--color-text-muted);
margin-top: 8px;
}
/* Clients Grid */
.clients-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
@media (max-width: 900px) {
.clients-grid { grid-template-columns: repeat(2, 1fr); }
.stats-row { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
.clients-grid { grid-template-columns: 1fr; }
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.client-card {
background: var(--glass-bg-light);
border-radius: 12px;
padding: 18px;
border: 1px solid var(--color-border);
border-radius: 18px;
background: var(--glass-bg-light);
border: 1px solid var(--color-border-light);
cursor: pointer;
transition: all 0.2s ease;
position: relative;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.client-card:hover {
background: var(--glass-bg-hover);
transform: translateY(-2px);
border-color: rgba(59, 130, 246, 0.24);
box-shadow: var(--shadow-md);
}
.client-header {
.client-card__header {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.client-card__title {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.client-status {
.client-card__header p {
color: var(--color-text-secondary);
font-size: 13px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-text-muted);
border-radius: 999px;
background: var(--color-error);
}
.client-status.online {
.status-dot.online {
background: var(--color-success);
box-shadow: 0 0 10px var(--color-success-glow);
}
.client-name {
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.client-id {
.state-pill {
height: fit-content;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
color: var(--color-text-muted);
margin: 0 0 8px 0;
font-family: monospace;
border: 1px solid transparent;
}
.client-info {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
.state-pill.online {
color: var(--color-success);
background: rgba(16, 185, 129, 0.12);
border-color: rgba(16, 185, 129, 0.2);
}
.state-pill.offline {
color: var(--color-text-secondary);
background: rgba(148, 163, 184, 0.12);
border-color: rgba(148, 163, 184, 0.2);
}
.client-card__meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.client-card__meta dt {
margin-bottom: 6px;
color: var(--color-text-muted);
font-size: 12px;
}
.client-card__meta dd {
color: var(--color-text-primary);
font-size: 13px;
word-break: break-word;
}
.install-grid {
display: grid;
gap: 14px;
}
.install-card {
padding: 16px;
border-radius: 16px;
background: var(--glass-bg-light);
border: 1px solid var(--color-border-light);
}
.install-card header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.client-tag {
display: inline-block;
padding: 4px 10px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
}
.client-tag.online {
background: rgba(16, 185, 129, 0.15);
color: var(--color-success);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.client-tag.offline {
background: var(--glass-bg-light);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
/* Button */
.glass-btn {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur-light);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 8px 16px;
color: var(--color-text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
}
.glass-btn:hover {
background: var(--glass-bg-hover);
transform: translateY(-1px);
}
.glass-btn.small { padding: 6px 12px; font-size: 12px; }
/* Heartbeat Indicator */
.heartbeat-indicator {
position: absolute;
top: 18px;
right: 18px;
}
.heartbeat-dot {
.install-card code {
display: block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-error);
color: var(--color-text-primary);
white-space: pre-wrap;
word-break: break-all;
font-size: 12px;
line-height: 1.7;
}
.heartbeat-indicator.online .heartbeat-dot {
background: var(--color-success);
animation: heartbeat-pulse 2s ease-in-out infinite;
box-shadow: 0 0 8px var(--color-success-glow);
.install-footnote {
color: var(--color-text-secondary);
font-size: 12px;
}
.heartbeat-indicator.offline .heartbeat-dot {
background: var(--color-error);
animation: none;
.empty-state {
padding: 48px 20px;
text-align: center;
color: var(--color-text-secondary);
background: var(--glass-bg-light);
border: 1px dashed var(--color-border);
border-radius: 16px;
}
@keyframes heartbeat-pulse {
0%, 100% {
box-shadow: 0 0 0 0 var(--color-success-glow);
transform: scale(1);
@media (max-width: 768px) {
.client-card__header {
flex-direction: column;
}
50% {
box-shadow: 0 0 0 8px rgba(16, 185, 129, 0);
transform: scale(1.1);
.client-card__meta {
grid-template-columns: 1fr;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More