Compare commits

..

124 Commits

Author SHA1 Message Date
sagit 3e52c8eace fix(frontend): remove blue outline on input focus (#113) 2026-02-14 02:37:59 +00:00
sagit 7808d57a79 Merge pull request #112 from Sagit-chu/opencode/proud-planet
fix(frontend): remove deep blue focus border
2026-02-14 10:16:33 +08:00
sagit 46bc4ca6e4 fix(frontend): remove deep blue focus border 2026-02-14 02:14:50 +00:00
sagit 28e66ab172 Merge pull request #111 from Sagit-chu/opencode/proud-planet
fix: apply remote entry runtime updates and input focus styling
2026-02-14 09:26:40 +08:00
sagit f19bccec4c Merge remote-tracking branch 'origin/main' into opencode/proud-planet 2026-02-14 01:25:21 +00:00
sagit e37d6cf666 fix(frontend): remove input inner shadow and improve focus visibility 2026-02-14 01:16:08 +00:00
sagit 177c2bc35f Merge pull request #110 from Sagit-chu/opencode/proud-planet
fix(backend): stabilize tls forwarding and udp ttl defaults
2026-02-13 22:42:27 +08:00
sagit 76c0978763 Merge branch 'main' into opencode/proud-planet 2026-02-13 22:40:19 +08:00
sagit fd1168d855 fix(backend): set udp ttl default for tls tunnel protocol 2026-02-13 14:22:13 +00:00
sagit 92c9590c1a fix(backend): apply entry chains for remote federation nodes
Ensure remote entry nodes receive AddChains during tunnel runtime apply while tolerating offline/timeout cases. Add focused contract coverage for online and offline remote entry behavior.
2026-02-13 13:59:17 +00:00
sagit 2afb1d275a Merge pull request #109 from Sagit-chu/opencode/playful-circuit
fix(backup): handle nullable tunnel export fields safely
2026-02-13 18:38:02 +08:00
sagit 880cd4cac5 fix(backup): handle nullable tunnel export fields safely 2026-02-13 10:34:58 +00:00
sagit a69a0f040b Merge branch 'main' into opencode/kind-planet 2026-02-13 17:48:27 +08:00
sagit cf6294a77d fix(backend): normalize strategy data and proxy ip parsing 2026-02-13 09:42:38 +00:00
sagit 524ee4cd95 Merge pull request #107 from Sagit-chu/opencode/curious-harbor
fix(backup): restore backup export/import APIs and route compatibility
2026-02-13 16:36:34 +08:00
sagit c049ceaacf fix(backend): resolve backup handler build conflict after main merge 2026-02-13 08:32:00 +00:00
sagit 3424221176 Merge branch 'main' into opencode/curious-harbor 2026-02-13 16:21:56 +08:00
sagit 5a9715eb26 fix(backup): restore backup export/import APIs and route compatibility 2026-02-13 08:17:34 +00:00
sagit 1b79213aed Merge pull request #106 from Sagit-chu/opencode/init-deep-agents
docs: update AGENTS.md hierarchy with new subdirectory docs
2026-02-13 16:11:51 +08:00
sagit c0d71125f4 Merge branch 'main' into opencode/init-deep-agents 2026-02-13 15:53:38 +08:00
sagit f01c0481cd docs: update AGENTS.md hierarchy with new subdirectory docs
- Update root AGENTS.md with expanded anti-patterns and notes
- Add handler/AGENTS.md for high-complexity backend handlers
- Add connector/AGENTS.md for GOST connector protocols
- Add socket/AGENTS.md for GOST socket utilities
2026-02-13 07:52:17 +00:00
sagit f227ffddc3 Merge pull request #105 from Sagit-chu/opencode/neon-rocket
fix: 修复备份
2026-02-13 15:33:25 +08:00
sagit c4f14f985e Merge remote-tracking branch 'origin/main' into opencode/neon-rocket 2026-02-13 07:30:53 +00:00
sagit 3b294c6b9e chore(frontend): fix HeroUI deps and apply lint cleanup 2026-02-13 07:25:27 +00:00
sagit 641aa66afc feat(backup): restore backup export/import flow 2026-02-13 07:25:13 +00:00
sagit fea1bf52f3 Merge pull request #102 from Sagit-chu/opencode/quick-comet
feat: 导入导出备份
2026-02-13 14:39:25 +08:00
sagit a72d84fa76 Merge branch 'main' into opencode/quick-comet 2026-02-13 14:21:00 +08:00
sagit 146821ebba Merge pull request #104 from Sagit-chu/opencode/tidy-cactus
fix: 修复共享节点作为入口的问题和调整docker 网络
2026-02-13 14:16:56 +08:00
sagit 0191f29cf1 chore(docker): update network subnet in compose files
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 06:02:09 +00:00
sagit 149a841a49 test(federation): add tests for remote node command and offline status
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 06:02:01 +00:00
sagit 229ae9e454 feat(backend): route node commands to remote panels
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 06:01:52 +00:00
sagit ae8a3db3df feat(federation): add remote node command support
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 06:01:42 +00:00
sagit 51660c413e Merge branch 'main' into opencode/quick-comet 2026-02-13 13:46:31 +08:00
sagit 2d2ca389e3 fix(backup): add transaction support and auto-backup before import
- Add transaction support for import operations with rollback on failure
- Add auto-backup before import to allow recovery on failure
- Convert user import to use INSERT ON CONFLICT pattern
- Add Execer interface to support both DB and Tx in import functions
2026-02-13 05:44:50 +00:00
sagit 3799729706 Merge pull request #103 from Sagit-chu/opencode/tidy-panda
fix: tls udp 转发
2026-02-13 13:15:06 +08:00
sagit 8628c35802 Merge branch 'main' into opencode/tidy-panda 2026-02-13 13:13:33 +08:00
sagit acea5ea76c fix(gost): filter expected net.ErrClosed noise in service handler
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 05:09:54 +00:00
sagit 8652380da1 feat(backend): TLS tunnel relay nodelay injection
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 05:09:46 +00:00
sagit dd206ced14 fix(permission): revoke inherited tunnel access after group unbind/removal 2026-02-13 03:28:21 +00:00
sagit f720b92f53 Merge branch 'main' into opencode/quick-comet 2026-02-13 11:10:09 +08:00
sagit f879a58bb4 feat(frontend): add backup export and import UI
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 03:06:32 +00:00
sagit b11283d488 feat(backend): add backup and restore functionality
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 03:06:23 +00:00
sagit 70f8dfeac1 Merge pull request #101 from Sagit-chu/opencode/eager-garden
fix(backend): implement keyword search in user list
2026-02-13 10:49:57 +08:00
sagit 37005a1954 Merge branch 'main' into opencode/eager-garden 2026-02-13 10:45:37 +08:00
sagit b55e056316 fix(backend): implement keyword search in user list
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 02:41:38 +00:00
sagit b3b7f5e56d Merge pull request #100 from Sagit-chu/opencode/gentle-comet
fix(tunnel): auto-update entry node IP on every tunnel update
2026-02-13 10:19:32 +08:00
sagit d6ff6ea500 Merge branch 'origin/main' into opencode/gentle-comet 2026-02-13 02:03:24 +00:00
sagit 9ed875b7ef fix(tunnel): auto-update entry node IP on every tunnel update 2026-02-13 01:24:48 +00:00
sagit 6387ce1816 Merge pull request #98 from Sagit-chu/opencode/glowing-orchid
fix(db): align sqlite/postgres SQL behavior and harden rewriter
2026-02-12 18:00:42 +08:00
sagit 219067a27c Merge branch 'main' into opencode/glowing-orchid 2026-02-12 17:55:41 +08:00
sagit 33678477aa fix(db): align sqlite/postgres SQL behavior and harden rewriter 2026-02-12 09:47:44 +00:00
sagit e33fbda8f4 Merge pull request #97 from Sagit-chu/opencode/glowing-orchid
fix: backend postgresql
2026-02-12 16:47:04 +08:00
sagit 62cb75f669 Merge branch 'main' into opencode/glowing-orchid 2026-02-12 16:45:46 +08:00
sagit f3260637db fix(install): prioritize explicit sqlite selection over stale DATABASE_URL
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-12 08:44:11 +00:00
sagit e43c6bf840 fix(postgres): avoid 42P18 during id default repair 2026-02-12 08:39:10 +00:00
sagit 025d0a3322 Merge pull request #96 from Sagit-chu/opencode/glowing-orchid
feat(postgres): add migration workflow and id self-heal
2026-02-12 16:06:06 +08:00
sagit 2dcfe15f00 Merge branch 'main' into opencode/glowing-orchid 2026-02-12 16:04:49 +08:00
sagit ecaae178b6 feat(postgres): add migration workflow and id self-heal 2026-02-12 08:02:34 +00:00
sagit 46a8ceff6a Merge pull request #95 from Sagit-chu/opencode/glowing-orchid
fix(docker): bump Go builder image to 1.24 to match go.mod requirement
2026-02-12 15:13:17 +08:00
sagit 7bfa1251c4 Merge branch 'main' into opencode/glowing-orchid 2026-02-12 15:12:12 +08:00
sagit cd09305a85 fix(docker): bump Go builder image to 1.24 to match go.mod requirement 2026-02-12 07:11:45 +00:00
sagit b5f8a491b3 Merge pull request #94 from Sagit-chu/opencode/glowing-orchid
docs: add PostgreSQL database guide and update FAQ with PG entries
2026-02-12 15:06:11 +08:00
sagit e4acd855e0 Merge branch 'main' into opencode/glowing-orchid 2026-02-12 15:05:01 +08:00
sagit 275cfdc65a docs: add PostgreSQL database guide and update FAQ with PG entries 2026-02-12 07:04:06 +00:00
sagit 9d323ce930 Merge pull request #93 from Sagit-chu/opencode/glowing-orchid
feat(postgres): add postgres backend support and migration docs
2026-02-12 14:53:54 +08:00
sagit 1733948a1b Merge branch 'main' into opencode/glowing-orchid
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-12 06:52:06 +00:00
sagit cedcaebd1f feat(postgres): add postgres backend support and migration docs 2026-02-12 06:38:25 +00:00
sagit b32133f81a Merge pull request #92 from Sagit-chu/opencode/lucky-otter
fix(upgrade): stabilize batch node upgrades
2026-02-12 13:37:43 +08:00
sagit ff57bca505 Merge branch 'main' into opencode/lucky-otter 2026-02-12 13:11:45 +08:00
sagit cdb2914dbf fix(upgrade): stabilize batch node upgrades under long-running operations 2026-02-12 04:57:58 +00:00
sagit b56d0a28e7 Merge pull request #91 from Sagit-chu/opencode/lucky-otter
fix(ws): prevent monitor websocket reconnect loop
2026-02-12 12:28:53 +08:00
sagit bdfc704f95 Merge branch 'main' into opencode/lucky-otter 2026-02-12 12:27:23 +08:00
sagit 9223892ca5 fix(ws): prevent monitor websocket reconnect loop 2026-02-12 04:25:47 +00:00
sagit 6f205df37c Merge pull request #90 from Sagit-chu/opencode/lucky-otter
fix(ws): stabilize node connectivity with ping/pong keepalive
2026-02-12 10:29:14 +08:00
sagit 04266165df Merge branch 'main' into opencode/lucky-otter 2026-02-12 10:28:04 +08:00
sagit dbd5773717 fix(ws): stabilize node connectivity with ping/pong keepalive 2026-02-12 02:27:10 +00:00
sagit 6c4d44e7a7 Merge pull request #89 from Sagit-chu/opencode/lucky-otter
fix(store): enable WAL mode and busy timeout to prevent SQLITE_BUSY
2026-02-12 09:09:42 +08:00
sagit 07b8d73956 Merge branch 'main' into opencode/lucky-otter 2026-02-12 09:08:36 +08:00
sagit 71a6a60077 fix(store): enable WAL mode and busy timeout to prevent SQLITE_BUSY errors
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-12 01:07:41 +00:00
sagit fe33028934 Merge pull request #87 from Sagit-chu/opencode/lucky-otter
fix: 节点管理按钮改为两行 grid 布局,防止卡片滑动溢出
2026-02-11 16:54:58 +08:00
sagit 01da4bd283 Merge branch 'main' into opencode/lucky-otter 2026-02-11 16:53:15 +08:00
sagit a0b975b62a chore: add go-gost/gost binary to .gitignore 2026-02-11 08:53:03 +00:00
sagit f4e56d091e fix: 节点管理按钮改为两行 grid 布局,防止卡片滑动溢出
- 操作按钮从单行 flex 改为两行 grid (3+2),避免窄屏挤压
- SortableItem 和 Card 添加 overflow-hidden,阻止内容溢出导致页面横滑
2026-02-11 08:45:58 +00:00
sagit 0a5335c1ca Merge pull request #86 from Sagit-chu/opencode/lucky-otter
fix: use systemd-run for agent upgrade/rollback restart
2026-02-11 16:15:55 +08:00
sagit d40e97d73b Merge branch 'main' into opencode/lucky-otter 2026-02-11 16:14:42 +08:00
sagit 73bf672e62 fix: use systemd-run for agent upgrade/rollback restart to avoid cgroup kill 2026-02-11 08:07:04 +00:00
sagit 297f526a92 Merge pull request #85 from Sagit-chu/opencode/lucky-otter
fix: use direct GitHub API for release queries instead of proxy
2026-02-11 15:46:21 +08:00
sagit 2b854d3172 Merge branch 'main' into opencode/lucky-otter 2026-02-11 15:43:18 +08:00
sagit c184a75f22 fix: use direct GitHub API for release queries instead of proxy
The gcode.hostcentral.cc proxy only supports github.com file downloads,
not api.github.com requests. API calls through the proxy returned 404
HTML pages, causing JSON decode error: invalid character '<'.

Also updates repo name from flux-panel to flvx across upgrade and
install URLs.
2026-02-11 07:37:30 +00:00
sagit 8a5bfa5aa8 Merge pull request #84 from Sagit-chu/opencode/lucky-otter
feat: complete agent upgrade system with batch upgrade
2026-02-11 15:09:34 +08:00
sagit 421f18d4da Merge branch 'main' into opencode/lucky-otter 2026-02-11 15:07:07 +08:00
sagit 5133e6f039 fix: remove unused handleUpgradeNode function 2026-02-11 07:04:50 +00:00
sagit 311840b29b feat: complete agent upgrade system with batch upgrade, version selection, progress reporting, and rollback 2026-02-11 07:01:00 +00:00
sagit 767f36668c Merge pull request #83 from Sagit-chu/opencode/shiny-squid
feat(node-install): add gcode.hostcentral.cc proxy to install script …
2026-02-11 14:01:06 +08:00
sagit 980509734d Merge branch 'main' into opencode/shiny-squid 2026-02-11 14:00:04 +08:00
sagit 65a6105469 feat(node-install): add gcode.hostcentral.cc proxy to install script download URL 2026-02-11 05:59:11 +00:00
sagit 0039b39609 Merge pull request #81 from Sagit-chu/opencode/shiny-squid
feat(panel-sharing): add edit support for peer shares
2026-02-11 11:16:25 +08:00
sagit 92ff7f568b Merge branch 'main' into opencode/shiny-squid 2026-02-11 11:12:03 +08:00
sagit 896f2bc5f8 fix(panel-sharing): display provider share deletion warnings for remote nodes
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-11 03:08:59 +00:00
sagit 87605ce8f8 fix(federation): sync remote node status on list and detect deleted provider shares
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-11 03:08:59 +00:00
sagit a5b191328d Merge pull request #82 from Sagit-chu/Sagit-chu-patch-1
Update README with project modifications and disclaimers
2026-02-11 11:00:41 +08:00
sagit 14063e66c3 Update README with project modifications and disclaimers
Removed outdated project details and added modifications section.
2026-02-11 10:59:02 +08:00
sagit 161be2c167 Merge branch 'main' into opencode/shiny-squid 2026-02-11 10:52:41 +08:00
sagit d6b83a0c1b feat(panel-sharing): add edit support for peer shares
Allow editing port range, traffic limit, allowed domains, allowed API
IPs and expiry time on existing shares via a new update endpoint and
edit modal in the frontend.
2026-02-11 02:48:57 +00:00
sagit 7aa5ca3e96 Merge pull request #80 from Sagit-chu/opencode/lucky-comet
docs: add caddy reverse proxy guide
2026-02-11 10:28:11 +08:00
sagit 7f69a130d0 Merge branch 'main' into opencode/lucky-comet 2026-02-11 10:26:30 +08:00
sagit 5ddb46c3c2 docs: add caddy reverse proxy guide 2026-02-11 02:25:16 +00:00
sagit 69f62188cf fix(panel-sharing): use adaptive units in formatFlowGB
fix(panel-sharing): use adaptive units in formatFlowGB to display sma…
2026-02-11 10:20:47 +08:00
sagit f786d60219 Merge branch 'main' into opencode/shiny-squid 2026-02-11 10:18:16 +08:00
sagit bdc3513a68 fix(panel-sharing): use adaptive units in formatFlowGB to display small traffic values 2026-02-11 02:17:38 +00:00
sagit acb20a13b7 Merge pull request #78 from Sagit-chu/fix/mirror-proxy-all-downloads
fix: route all downloads through mirror proxy for IPv6 support
2026-02-11 09:50:23 +08:00
sagit c890ec783d fix: route all downloads through mirror proxy for IPv6 support 2026-02-11 01:47:52 +00:00
sagit 8ccc5e054a Merge pull request #77 from Sagit-chu/opencode/shiny-squid
fix: Panel Peering
2026-02-10 20:13:22 +08:00
sagit aef329149d Merge branch 'main' into opencode/shiny-squid 2026-02-10 20:08:08 +08:00
sagit 8473b9040f fix(panel-sharing): disable cursor animation to fix scroll offset issue
Co-authored-by: Sisyphus <sisyphus@opencode.ai>
2026-02-10 12:04:51 +00:00
sagit 89ab12dcdf fix(federation): clean up runtimes on share delete and sync live traffic from provider
Co-authored-by: Sisyphus <sisyphus@opencode.ai>
2026-02-10 11:58:00 +00:00
sagit 919339cdb1 fix(federation): cascade delete peer_share_runtime rows on share deletion
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-10 11:58:00 +00:00
sagit 6a8996b17a Merge pull request #76 from Sagit-chu/opencode/cosmic-falcon
feat(install): support version-pinned install from release assets
2026-02-10 18:56:24 +08:00
sagit 7557e02e54 feat(install): support version-pinned install from release assets
Add PINNED_VERSION mechanism so scripts downloaded from a specific
release (e.g. 2.1.0) install that exact version instead of latest.
CI injects the version via sed during release build. Users can still
override with VERSION= env var. Update release notes and README to
show both pinned and latest install options.
2026-02-10 10:54:17 +00:00
sagit 90debf144b Merge pull request #75 from Sagit-chu/opencode/shiny-squid
feat(federation): add share flow reset and remote usage visibility
2026-02-10 18:30:01 +08:00
sagit cec5fe9adb Merge branch 'main' into opencode/shiny-squid 2026-02-10 18:25:53 +08:00
sagit 6790336885 style(node): move drag handle to left of status chips 2026-02-10 10:23:53 +00:00
sagit 5aea281e16 fix(node): hide local-only metrics and actions for remote nodes 2026-02-10 10:13:36 +00:00
sagit 3b697f4d13 feat(federation): add share flow reset and remote usage visibility 2026-02-10 10:04:31 +00:00
53 changed files with 7736 additions and 590 deletions
+66 -2
View File
@@ -112,6 +112,12 @@ jobs:
upx --best --lzma gost-amd64
upx --best --lzma gost-arm64
- name: Generate SHA256 checksums
working-directory: ./go-gost
run: |
sha256sum gost-amd64 > gost-amd64.sha256
sha256sum gost-arm64 > gost-arm64.sha256
- name: Upload GOST AMD64 artifact
uses: actions/upload-artifact@v4
with:
@@ -124,6 +130,18 @@ jobs:
name: gost-binary-arm64
path: ./go-gost/gost-arm64
- name: Upload GOST AMD64 checksum artifact
uses: actions/upload-artifact@v4
with:
name: gost-checksum-amd64
path: ./go-gost/gost-amd64.sha256
- name: Upload GOST ARM64 checksum artifact
uses: actions/upload-artifact@v4
with:
name: gost-checksum-arm64
path: ./go-gost/gost-arm64.sha256
build-vite:
name: Build & Push Vite Frontend
needs: check-version
@@ -238,7 +256,20 @@ jobs:
name: gost-binary-arm64
path: ./artifacts/arm64
- name: Download GOST AMD64 checksum
uses: actions/download-artifact@v4
with:
name: gost-checksum-amd64
path: ./artifacts/
- name: Download GOST ARM64 checksum
uses: actions/download-artifact@v4
with:
name: gost-checksum-arm64
path: ./artifacts/
- name: Prepare release files
run: |
VERSION="${{ needs.check-version.outputs.version }}"
OWNER="${{ needs.check-version.outputs.image_owner }}"
@@ -268,6 +299,10 @@ jobs:
sed -i "s|2.0.7-beta|${VERSION}|g" ./artifacts/install.sh
sed -i "s|2.0.7-beta|${VERSION}|g" ./artifacts/panel_install.sh
# 注入固定版本号,使从 Release 页下载的脚本只安装该版本
sed -i "s|^PINNED_VERSION=\"\"|PINNED_VERSION=\"${VERSION}\"|" ./artifacts/install.sh
sed -i "s|^PINNED_VERSION=\"\"|PINNED_VERSION=\"${VERSION}\"|" ./artifacts/panel_install.sh
- name: Create Release
env:
GH_TOKEN: ${{ github.token }}
@@ -303,14 +338,22 @@ jobs:
## 🚀 Quick Install
**Panel:**
**Panel (安装此版本 ${VERSION}):**
\`\`\`bash
curl -L https://github.com/${{ github.repository }}/releases/download/${VERSION}/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
\`\`\`
**Node:**
**Node (安装此版本 ${VERSION}):**
\`\`\`bash
curl -L https://github.com/${{ github.repository }}/releases/download/${VERSION}/install.sh -o install.sh && chmod +x install.sh && ./install.sh
\`\`\`
**安装最新版:**
\`\`\`bash
# 面板端
curl -L https://raw.githubusercontent.com/${{ github.repository }}/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
# 节点端
curl -L https://raw.githubusercontent.com/${{ github.repository }}/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
\`\`\`" \
--repo ${{ github.repository }}
@@ -319,6 +362,10 @@ jobs:
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
echo "📤 上传 GOST 校验文件..."
gh release upload "${VERSION}" ./artifacts/gost-amd64.sha256 --clobber
gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber
echo "📤 上传安装脚本..."
gh release upload "${VERSION}" ./artifacts/install.sh --clobber
gh release upload "${VERSION}" ./artifacts/panel_install.sh --clobber
@@ -351,6 +398,18 @@ jobs:
name: gost-binary-arm64
path: ./artifacts/arm64
- name: Download GOST AMD64 checksum
uses: actions/download-artifact@v4
with:
name: gost-checksum-amd64
path: ./artifacts/
- name: Download GOST ARM64 checksum
uses: actions/download-artifact@v4
with:
name: gost-checksum-arm64
path: ./artifacts/
- name: Rename binaries
run: |
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
@@ -367,4 +426,9 @@ jobs:
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
echo "📤 上传 GOST 校验文件..."
gh release upload "${VERSION}" ./artifacts/gost-amd64.sha256 --clobber
gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber
echo "✅ GOST 二进制文件更新完成"
+4
View File
@@ -177,6 +177,7 @@ build/
*.dylib
your_app.exe
go-backend/paneld
go-gost/gost
# Go 测试二进制文件
*.test
@@ -262,3 +263,6 @@ sql/
!go-backend/internal/store/sqlite/sql/
!go-backend/internal/store/sqlite/sql/schema.sql
!go-backend/internal/store/sqlite/sql/data.sql
!go-backend/internal/store/postgres/sql/
!go-backend/internal/store/postgres/sql/schema.sql
!go-backend/internal/store/postgres/sql/data.sql
+11 -4
View File
@@ -1,8 +1,8 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
**Commit:** 7ca01ab
**Branch:** beta
**Generated:** Fri Feb 13 2026
**Commit:** 3799729
**Branch:** (detached)
## OVERVIEW
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
@@ -47,7 +47,11 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
- `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
## ANTI-PATTERNS (THIS PROJECT)
- Do not edit generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
- **DO NOT EDIT** generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
- **DO NOT ADD** `Bearer` prefix to Authorization header - expects raw JWT token.
- **DO NOT MODIFY** `install.sh` or `panel_install.sh` locally - CI overwrites these on release.
- **DO NOT USE** ORM in backend - uses raw SQL with `database/sql`.
- **DO NOT ADD** frontend tests - project has no test infrastructure (Vitest/Jest not configured).
## COMMANDS
```bash
@@ -68,3 +72,6 @@ docker compose -f docker-compose-v6.yml up -d
## NOTES
- LSP servers are not installed in this environment (gopls/jdtls/typescript-language-server); rely on grep-based navigation.
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
- Install scripts (`install.sh`, `panel_install.sh`) self-delete after execution - common pattern in one-liner installs.
- CI uses UPX compression on Go binaries before release.
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure.
+122 -30
View File
@@ -2,6 +2,127 @@
> **联系我们**: [Telegram群组](https://t.me/flvxpanel)
## 特性
- 支持按 **隧道账号级别** 管理流量转发数量,可用于用户/隧道配额控制
- 支持 **TCP** 和 **UDP** 协议的转发
- 支持两种转发模式:**端口转发** 与 **隧道转发**
- 可针对 **指定用户的指定隧道进行限速** 设置
- 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型
- 提供灵活的转发策略配置,适用于多种网络场景
- 面板分享,支持将节点分享给其他人,面板对接面板
- 支持分组权限管理,隧道分组、用户分组
- 支持批量功能,可以批量下发配置,启停等
- 支持隧道修改配置、转发修改隧道
## 部署流程
---
### Docker Compose部署
#### 快速部署(安装最新版)
面板端:
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
节点端:
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
#### 安装特定版本
从 [Releases](https://github.com/Sagit-chu/flux-panel/releases) 页面复制对应版本的安装命令,脚本会自动安装该版本而非最新版。
面板端(以 2.1.0 为例):
```bash
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
节点端(以 2.1.0 为例):
```bash
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
#### PostgreSQL 部署(Docker Compose)
安装脚本会根据环境自动下载对应的 Compose 配置并保存为 `docker-compose.yml`。默认仍使用 SQLite,切换到 PostgreSQL 只需要配置环境变量。
1) 在 `docker-compose` 同目录创建或修改 `.env`:
```bash
JWT_SECRET=replace_with_your_secret
BACKEND_PORT=6365
FRONTEND_PORT=6366
DB_TYPE=postgres
DATABASE_URL=postgres://flux_panel:replace_with_strong_password@postgres:5432/flux_panel?sslmode=disable
POSTGRES_DB=flux_panel
POSTGRES_USER=flux_panel
POSTGRES_PASSWORD=replace_with_strong_password
```
> 📌 使用安装脚本部署时,`POSTGRES_PASSWORD` 会自动随机生成并写入 `.env`。
2) 启动服务:
```bash
docker compose up -d
```
3) 如果你想继续使用 SQLite,保留 `DB_TYPE=sqlite`(或不设置 `DB_TYPE`)即可。
#### 从 SQLite 迁移到 PostgreSQL
如果你是通过 `panel_install.sh` 安装面板,推荐直接使用脚本菜单一键迁移:
```bash
./panel_install.sh
# 选择 4. 迁移到 PostgreSQL
```
脚本会自动完成 SQLite 备份、PostgreSQL 启动、`pgloader` 导入、`.env` 中 `DB_TYPE`/`DATABASE_URL` 更新,并重启服务。
如果你希望手动迁移,以下示例基于 Docker Volume `sqlite_data`(项目默认配置)与 `pgloader`:
1) 停止服务并备份 SQLite 数据:
```bash
docker compose down
docker run --rm -v sqlite_data:/data -v "$(pwd)":/backup alpine sh -c "cp /data/gost.db /backup/gost.db.bak"
```
2) 仅启动 PostgreSQL:
```bash
docker compose up -d postgres
```
3) 使用 `pgloader` 迁移:
```bash
source .env
docker run --rm --network gost-network -v sqlite_data:/sqlite dimitri/pgloader:latest pgloader /sqlite/gost.db "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
```
4) 切换后端到 PostgreSQL 并启动:
```bash
source .env
export DB_TYPE=postgres
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
docker compose up -d
```
5) 迁移完成后,登录面板检查用户、隧道、转发、节点数据是否正确。
#### 默认管理员账号
- **账号**: admin_user
- **密码**: admin_user
> ⚠️ 首次登录后请立即修改默认密码!
---
## Original Project
- **Name**: flux-panel
- **Source**: https://github.com/bqlpfy/flux-panel
@@ -26,40 +147,11 @@ The following major changes and additions have been made in this fork (FLVX):
- **Removed**: `ios-app/` - Source code for the iOS client.
### 5. Infrastructure & Scripts
- **Modified**: `docker-compose-v4.yml`, `docker-compose-v6.yml` (Updated for Go backend).
- **Modified**: `docker-compose.yml` (installer output name, auto-selects IPv4/IPv6 template, updated for Go backend).
- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic).
- **Added**: `AGENTS.md` (Project documentation).
---
## 特性
- 支持按 **隧道账号级别** 管理流量转发数量,可用于用户/隧道配额控制
- 支持 **TCP** 和 **UDP** 协议的转发
- 支持两种转发模式:**端口转发** 与 **隧道转发**
- 可针对 **指定用户的指定隧道进行限速** 设置
- 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型
- 提供灵活的转发策略配置,适用于多种网络场景
## 部署流程
---
### Docker Compose部署
#### 快速部署
面板端:
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
节点端:
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
#### 默认管理员账号
- **账号**: admin_user
- **密码**: admin_user
> ⚠️ 首次登录后请立即修改默认密码!
## 免责声明
+16
View File
@@ -22,3 +22,19 @@
### Q5: IPv6 无法使用?
**A**: 面板安装脚本会自动尝试配置 Docker 的 IPv6。如果失败,请手动检查 `/etc/docker/daemon.json` 配置,确保 `ipv6: true` 且分配了正确的 `fixed-cidr-v6` 子网。
### Q6: 如何切换到 PostgreSQL?
**A**: 在 `.env` 文件中设置 `DB_TYPE=postgres`,并让 `DATABASE_URL` 与 `POSTGRES_*` 保持一致,然后执行 `docker compose up -d` 重启服务即可。使用安装脚本部署时,`POSTGRES_PASSWORD` 会自动随机生成并写入 `.env`。详见 [PostgreSQL 数据库指南](./postgresql.md)。
### Q7: 从 SQLite 迁移到 PostgreSQL 后数据丢失?
**A**:
1. 确认迁移前已备份 SQLite 文件(`gost.db.bak`)。
2. 确认 `pgloader` 命令执行成功,检查其输出是否有报错。
3. 确认 `.env` 中 `DATABASE_URL` 的密码与 `POSTGRES_PASSWORD` 一致。
4. 详细迁移步骤参考 [PostgreSQL 数据库指南 - 从 SQLite 迁移](./postgresql.md)。
### Q8: PostgreSQL 容器启动失败?
**A**:
1. 检查 `POSTGRES_PASSWORD` 是否已设置(不能为空)。
2. 查看容器日志:`docker logs flux-panel-postgres`。
3. 如果是首次启动后修改了密码,需要删除旧的数据卷重新初始化:`docker volume rm postgres_data`。
+1
View File
@@ -17,6 +17,7 @@
- [安装部署](./install.md)
- [使用指南](./usage.md)
- [PostgreSQL 数据库指南](./postgresql.md)
- [常见问题](./faq.md)
## 免责声明
+56
View File
@@ -41,6 +41,8 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_instal
1. 安装面板
2. 更新面板
3. 卸载面板
4. 迁移到 PostgreSQL
5. 退出
---
@@ -77,3 +79,57 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -
安装完成后,服务会自动启动。
- 查看状态: `systemctl status flux_agent`
- 回到面板 **节点管理** 页面,该节点状态应显示为 **在线**。
---
## 三、Caddy 反向代理(可选)
如果需要通过域名访问面板并自动获取 HTTPS 证书,可以使用 Caddy 作为反向代理。
### 1. 安装 Caddy
```bash
# Debian / Ubuntu
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudflare.com/content/v1/e2qwFJ2fRP2b2q/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudflare.com/content/v1/e2qwFJ2fRP2b2q/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
```
其他系统请参考 [Caddy 官方安装文档](https://caddyserver.com/docs/install)。
### 2. 配置 Caddyfile
编辑 Caddy 配置文件:
```bash
sudo nano /etc/caddy/Caddyfile
```
#### 面板域名配置
将 `panel.example.com` 替换为你自己的域名:
```caddyfile
panel.example.com {
reverse_proxy localhost:6366
}
```
Caddy 会自动为域名申请和续期 HTTPS 证书,无需额外配置。
### 3. 重启 Caddy
```bash
sudo systemctl restart caddy
```
### 4. 注意事项
- 确保域名已正确解析到服务器 IP。
- 确保服务器防火墙放行了 **80** 和 **443** 端口(Caddy 自动申请证书需要)。
- 使用 Caddy 反向代理后,可以在 `.env` 中将前端端口改为仅监听本地,避免直接暴露:
```
FRONTEND_PORT=127.0.0.1:6366
```
+274
View File
@@ -0,0 +1,274 @@
# PostgreSQL 数据库指南
FLVX 默认使用 SQLite 作为数据库,同时也内置了对 PostgreSQL 的完整支持。本文档介绍如何使用 PostgreSQL 部署面板、从 SQLite 迁移以及日常维护。
## 一、SQLite 与 PostgreSQL 对比
| 特性 | SQLite | PostgreSQL |
|------|--------|------------|
| **部署复杂度** | 零配置,开箱即用 | 需要额外的数据库服务 |
| **并发性能** | 适合小规模单机使用 | 支持高并发读写 |
| **数据规模** | 适合中小规模数据 | 适合大规模数据 |
| **备份与恢复** | 直接复制文件 | 支持逻辑备份与物理备份 |
| **高可用** | 不支持 | 支持主从复制、流复制 |
**建议**:如果你只是个人使用或小团队使用,SQLite 完全够用。如果节点多,推荐使用 PostgreSQL。
---
## 二、环境变量说明
以下环境变量用于配置数据库连接,在 `.env` 文件或 Docker Compose `environment` 中设置。
### 后端服务 (backend) 使用
| 变量名 | 说明 | 默认值 | 示例 |
|--------|------|--------|------|
| `DB_TYPE` | 数据库类型,`sqlite` 或 `postgres` | `sqlite` | `postgres` |
| `DATABASE_URL` | PostgreSQL 连接字符串(仅 `DB_TYPE=postgres` 时必填) | 空 | `postgres://flux_panel:密码@postgres:5432/flux_panel?sslmode=disable` |
| `DB_PATH` | SQLite 数据库文件路径(仅 `DB_TYPE=sqlite` 时使用) | `/app/data/gost.db` | `/app/data/gost.db` |
### PostgreSQL 容器使用
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `POSTGRES_DB` | 数据库名称 | `flux_panel` |
| `POSTGRES_USER` | 数据库用户名 | `flux_panel` |
| `POSTGRES_PASSWORD` | 数据库密码 | `flux_panel_change_me` |
> ⚠️ **安全提示**:生产环境中请务必修改 `POSTGRES_PASSWORD` 为强密码,不要使用默认值!
---
## 三、全新部署(Docker Compose + PostgreSQL)
安装脚本会根据环境自动下载对应的 Compose 配置并保存为 `docker-compose.yml`。默认使用 SQLite,只需配置环境变量即可切换到 PostgreSQL。
### 1. 创建 `.env` 文件
在 `docker-compose` 同目录创建 `.env` 文件:
```bash
# 基础配置
JWT_SECRET=替换为你的密钥
BACKEND_PORT=6365
FRONTEND_PORT=6366
# PostgreSQL 配置
DB_TYPE=postgres
DATABASE_URL=postgres://flux_panel:替换为强密码@postgres:5432/flux_panel?sslmode=disable
POSTGRES_DB=flux_panel
POSTGRES_USER=flux_panel
POSTGRES_PASSWORD=替换为强密码
```
> ⚠️ `DATABASE_URL` 中的密码必须与 `POSTGRES_PASSWORD` 保持一致。
### 2. 启动服务
```bash
docker compose up -d
```
### 3. 验证
```bash
# 检查所有容器是否正常运行
docker ps
# 查看后端日志,确认连接 PostgreSQL 成功
docker logs flux-panel-backend
# 查看 PostgreSQL 容器状态
docker logs flux-panel-postgres
```
---
## 四、从 SQLite 迁移到 PostgreSQL
如果你已经在使用 SQLite 并且希望迁移到 PostgreSQL,请按照以下步骤操作。
### 快速方式:脚本菜单一键迁移(推荐)
如果你是通过安装脚本部署面板,可直接执行:
```bash
./panel_install.sh
# 选择 4. 迁移到 PostgreSQL
```
脚本会自动完成以下操作:
- 备份 SQLite 数据到当前目录(`gost.db.bak`)
- 启动并等待 PostgreSQL 健康检查通过
- 使用 `pgloader` 导入 SQLite 数据
- 自动写入 `.env` 的 `DB_TYPE=postgres` 与 `DATABASE_URL`
- 重启服务并等待后端健康检查
### 手动方式:按步骤迁移
### 1. 备份 SQLite 数据
```bash
# 停止所有服务
docker compose down
# 备份 SQLite 数据文件到当前目录
docker run --rm -v sqlite_data:/data -v "$(pwd)":/backup alpine sh -c "cp /data/gost.db /backup/gost.db.bak"
```
### 2. 配置 PostgreSQL 环境变量
在 `.env` 文件中添加 PostgreSQL 配置(参考上方"环境变量说明")。
### 3. 仅启动 PostgreSQL
```bash
docker compose up -d postgres
```
等待 PostgreSQL 完全就绪:
```bash
# 检查 PostgreSQL 健康状态
docker inspect --format='{{.State.Health.Status}}' flux-panel-postgres
# 输出 "healthy" 表示就绪
```
### 4. 使用 pgloader 迁移数据
```bash
source .env
docker run --rm \
--network gost-network \
-v sqlite_data:/sqlite \
dimitri/pgloader:latest \
pgloader /sqlite/gost.db "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
```
> 📌 建议直接从 `.env` 读取 `POSTGRES_USER`、`POSTGRES_PASSWORD`、`POSTGRES_DB`,避免手填密码导致认证失败。
### 5. 启动全部服务
```bash
source .env
export DB_TYPE=postgres
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
docker compose up -d
```
### 6. 验证迁移
登录面板后,检查以下数据是否完整:
- 用户列表和权限
- 节点信息和状态
- 隧道配置
- 转发规则
- 流量统计数据
---
## 五、独立 PostgreSQL(非 Docker)
如果你不想使用 Docker Compose 中自带的 PostgreSQL 容器,也可以连接外部的 PostgreSQL 实例。
### 1. 准备 PostgreSQL
在目标 PostgreSQL 服务器上创建数据库和用户:
```sql
CREATE USER flux_panel WITH PASSWORD '你的强密码';
CREATE DATABASE flux_panel OWNER flux_panel;
```
### 2. 配置连接
修改 `.env` 文件,将 `DATABASE_URL` 指向外部 PostgreSQL:
```bash
DB_TYPE=postgres
DATABASE_URL=postgres://flux_panel:你的强密码@数据库地址:5432/flux_panel?sslmode=disable
```
> 📌 如果 PostgreSQL 在远程服务器且启用了 SSL,请将 `sslmode=disable` 改为 `sslmode=require` 或 `sslmode=verify-full`。
### 3. 停用内置 PostgreSQL 容器(可选)
如果使用外部 PostgreSQL,可以在启动时不启动内置的 postgres 服务:
```bash
docker compose up -d backend frontend
```
---
## 六、数据备份与恢复
### 逻辑备份(pg_dump)
```bash
# 备份(在 Docker 环境下)
docker exec flux-panel-postgres pg_dump -U flux_panel flux_panel > backup_$(date +%Y%m%d_%H%M%S).sql
# 恢复
docker exec -i flux-panel-postgres psql -U flux_panel flux_panel < backup_20260101_120000.sql
```
### 定时备份(cron)
创建备份脚本 `/opt/flvx/backup.sh`:
```bash
#!/bin/bash
BACKUP_DIR="/opt/flvx/backups"
mkdir -p "$BACKUP_DIR"
docker exec flux-panel-postgres pg_dump -U flux_panel flux_panel | gzip > "$BACKUP_DIR/flvx_$(date +%Y%m%d_%H%M%S).sql.gz"
# 清理 30 天前的备份
find "$BACKUP_DIR" -name "flvx_*.sql.gz" -mtime +30 -delete
```
添加 cron 任务(每天凌晨 3 点执行):
```bash
chmod +x /opt/flvx/backup.sh
echo "0 3 * * * /opt/flvx/backup.sh" | crontab -
```
---
## 七、常见问题
### Q: 切换到 PostgreSQL 后启动失败,提示连接被拒绝?
**A**:
1. 确认 PostgreSQL 容器已启动并处于 `healthy` 状态:`docker ps`。
2. 确认 `DATABASE_URL` 中的主机名、端口、用户名、密码正确。
3. 在 Docker Compose 环境下,主机名应为 `postgres`(服务名),而非 `localhost`。
### Q: pgloader 迁移时报错?
**A**:
1. 确认 PostgreSQL 容器已完全就绪(状态为 `healthy`)。
2. 确认 `--network gost-network` 参数正确,使 pgloader 容器与 PostgreSQL 在同一网络中。
3. 如果数据库已有表结构,pgloader 可能会报冲突。可以先清空目标数据库后重试。
### Q: 如何查看当前使用的数据库类型?
**A**: 查看后端容器的 `DB_TYPE` 环境变量:
```bash
docker exec flux-panel-backend printenv DB_TYPE
```
### Q: 可以同时使用 SQLite 和 PostgreSQL 吗?
**A**: 不可以。`DB_TYPE` 只能设置为 `sqlite` 或 `postgres` 之一。后端启动时根据此配置连接对应的数据库。
### Q: PostgreSQL 数据存储在哪里?
**A**: 在 Docker Compose 部署中,PostgreSQL 数据存储在名为 `postgres_data` 的 Docker Volume 中。可以通过以下命令查看:
```bash
docker volume inspect postgres_data
```
+31 -5
View File
@@ -7,16 +7,17 @@ services:
driver: json-file
options:
max-size: "20m"
max-file: "3"
environment:
DB_TYPE: ${DB_TYPE:-sqlite}
DB_PATH: /app/data/gost.db
DATABASE_URL: ${DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET}
LOG_DIR: /app/logs
SERVER_ADDR: :6365
TZ: Asia/Shanghai
ports:
- "${BACKEND_PORT}:6365"
volumes:
- backend_logs:/app/logs
- sqlite_data:/app/data
networks:
- gost-network
@@ -29,6 +30,30 @@ services:
retries: 5
start_period: 30s
postgres:
image: postgres:16-alpine
container_name: flux-panel-postgres
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
environment:
POSTGRES_DB: ${POSTGRES_DB:-flux_panel}
POSTGRES_USER: ${POSTGRES_USER:-flux_panel}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flux_panel_change_me}
TZ: Asia/Shanghai
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- gost-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-flux_panel} -d ${POSTGRES_DB:-flux_panel}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
frontend:
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
container_name: vite-frontend
@@ -37,6 +62,7 @@ services:
driver: json-file
options:
max-size: "20m"
max-file: "3"
ports:
- "${FRONTEND_PORT}:80"
depends_on:
@@ -50,8 +76,8 @@ volumes:
sqlite_data:
name: sqlite_data
driver: local
backend_logs:
name: backend_logs
postgres_data:
name: postgres_data
driver: local
@@ -61,4 +87,4 @@ networks:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
- subnet: 172.80.0.0/16
+31 -5
View File
@@ -7,16 +7,17 @@ services:
driver: json-file
options:
max-size: "20m"
max-file: "3"
environment:
DB_TYPE: ${DB_TYPE:-sqlite}
DB_PATH: /app/data/gost.db
DATABASE_URL: ${DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET}
LOG_DIR: /app/logs
SERVER_ADDR: :6365
TZ: Asia/Shanghai
ports:
- "${BACKEND_PORT}:6365"
volumes:
- backend_logs:/app/logs
- sqlite_data:/app/data
networks:
- gost-network
@@ -29,6 +30,30 @@ services:
retries: 5
start_period: 30s
postgres:
image: postgres:16-alpine
container_name: flux-panel-postgres
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
environment:
POSTGRES_DB: ${POSTGRES_DB:-flux_panel}
POSTGRES_USER: ${POSTGRES_USER:-flux_panel}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flux_panel_change_me}
TZ: Asia/Shanghai
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- gost-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-flux_panel} -d ${POSTGRES_DB:-flux_panel}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
frontend:
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
container_name: vite-frontend
@@ -37,6 +62,7 @@ services:
driver: json-file
options:
max-size: "20m"
max-file: "3"
ports:
- "${FRONTEND_PORT}:80"
depends_on:
@@ -50,8 +76,8 @@ volumes:
sqlite_data:
name: sqlite_data
driver: local
backend_logs:
name: backend_logs
postgres_data:
name: postgres_data
driver: local
@@ -62,5 +88,5 @@ networks:
enable_ipv6: true
ipam:
config:
- subnet: 172.20.0.0/16
- subnet: 172.80.0.0/16
- subnet: fd00:dead:beef::/48
+2 -2
View File
@@ -1,7 +1,7 @@
FROM golang:1.23-bookworm AS builder
FROM golang:1.24-bookworm AS builder
WORKDIR /src
COPY go.mod ./
COPY go.mod go.sum ./
RUN go mod download
COPY . .
+8 -1
View File
@@ -1,22 +1,29 @@
module go-backend
go 1.23.0
go 1.24.0
toolchain go1.24.4
require (
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.7.3
modernc.org/sqlite v1.37.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.29.0 // indirect
modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+32 -6
View File
@@ -1,3 +1,6 @@
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/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/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
@@ -6,23 +9,46 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.3 h1:PO1wNKj/bTAwxSJnO1Z4Ai8j4magtqg2SLNjEDzcXQo=
github.com/jackc/pgx/v5 v5.7.3/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
+19 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"strings"
"time"
"go-backend/internal/config"
@@ -20,9 +21,24 @@ type App struct {
}
func New(cfg config.Config) (*App, error) {
repo, err := sqlite.Open(cfg.DBPath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
var (
repo *sqlite.Repository
err error
)
switch strings.ToLower(strings.TrimSpace(cfg.DBType)) {
case "", "sqlite":
repo, err = sqlite.Open(cfg.DBPath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
case "postgres", "postgresql":
repo, err = sqlite.OpenPostgres(cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("open postgres: %w", err)
}
default:
return nil, fmt.Errorf("unsupported DB_TYPE %q", cfg.DBType)
}
h := handler.New(repo, cfg.JWTSecret)
+12 -8
View File
@@ -3,18 +3,22 @@ package config
import "os"
type Config struct {
Addr string
DBPath string
JWTSecret string
LogDir string
Addr string
DBType string
DBPath string
DatabaseURL string
JWTSecret string
LogDir string
}
func FromEnv() Config {
cfg := Config{
Addr: getEnv("SERVER_ADDR", ":6365"),
DBPath: getEnv("DB_PATH", "/app/data/gost.db"),
JWTSecret: getEnv("JWT_SECRET", ""),
LogDir: getEnv("LOG_DIR", "/app/logs"),
Addr: getEnv("SERVER_ADDR", ":6365"),
DBType: getEnv("DB_TYPE", "sqlite"),
DBPath: getEnv("DB_PATH", "/app/data/gost.db"),
DatabaseURL: getEnv("DATABASE_URL", ""),
JWTSecret: getEnv("JWT_SECRET", ""),
LogDir: getEnv("LOG_DIR", "/app/logs"),
}
return cfg
@@ -21,6 +21,7 @@ type RemoteNodeInfo struct {
ServerIP string `json:"serverIp"`
Status int `json:"status"`
MaxBandwidth int64 `json:"maxBandwidth"`
CurrentFlow int64 `json:"currentFlow"`
ExpiryTime int64 `json:"expiryTime"`
PortRangeStart int `json:"portRangeStart"`
PortRangeEnd int `json:"portRangeEnd"`
@@ -76,6 +77,18 @@ type RuntimeDiagnoseRequest struct {
Timeout int `json:"timeout"`
}
type RuntimeNodeCommandRequest struct {
CommandType string `json:"commandType"`
Data interface{} `json:"data"`
}
type RuntimeNodeCommandResponse struct {
Type string `json:"type"`
Success bool `json:"success"`
Message string `json:"message"`
Data map[string]interface{} `json:"data,omitempty"`
}
func NewFederationClient() *FederationClient {
return &FederationClient{
client: &http.Client{
@@ -84,6 +97,14 @@ func NewFederationClient() *FederationClient {
}
}
func NewFederationClientWithTimeout(timeout time.Duration) *FederationClient {
return &FederationClient{
client: &http.Client{
Timeout: timeout,
},
}
}
func (c *FederationClient) Connect(url, token, localDomain string) (*RemoteNodeInfo, error) {
url = strings.TrimSuffix(url, "/")
req, err := http.NewRequest("POST", url+"/api/v1/federation/connect", nil)
@@ -324,3 +345,42 @@ func (c *FederationClient) Diagnose(url, token, localDomain string, reqData Runt
return res.Data, nil
}
func (c *FederationClient) Command(url, token, localDomain string, reqData RuntimeNodeCommandRequest) (*RuntimeNodeCommandResponse, error) {
url = strings.TrimSuffix(url, "/")
bodyBytes, _ := json.Marshal(reqData)
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/command", strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if localDomain != "" {
req.Header.Set("X-Panel-Domain", localDomain)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
}
var res struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data RuntimeNodeCommandResponse `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}
if res.Code != 0 {
return nil, fmt.Errorf("remote api error: %s", res.Msg)
}
return &res.Data, nil
}
@@ -0,0 +1,43 @@
# BACKEND HTTP HANDLER KNOWLEDGE BASE
**Generated:** Fri Feb 13 2026
## OVERVIEW
HTTP request handlers for FLVX Admin API. Core business logic layer.
**Stack:** Go 1.23, net/http, raw SQL (no ORM).
## STRUCTURE
```
handler/
├── handler.go # Main Handler struct, login/captcha, job scheduling
├── control_plane.go # Node control plane API (add/delete/list)
├── federation.go # Federation/cluster sync API
├── flow_policy.go # Traffic policy API
├── jobs.go # Background job management (sync, cleanup)
├── mutations.go # CRUD for users, tunnels, forwards (largest: 100k+ LOC)
└── upgrade.go # System upgrade API
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **User/Tunnel CRUD** | `mutations.go` | Largest file; all create/update/delete ops |
| **Login/Captcha** | `handler.go` | Login flow, captcha verification |
| **Federation Sync** | `federation.go` | Panel-to-panel sync |
| **Traffic Policies** | `flow_policy.go` | Flow limiting, quota management |
| **Background Jobs** | `jobs.go` | Scheduled sync/cleanup tasks |
## CONVENTIONS
- Inherits from parent: raw SQL, no ORM, JWT in Authorization header.
- Large files expected (`mutations.go` >100k LOC).
- Uses `sqlite.Repository` for DB access via `repo.XXX()` methods.
## ANTI-PATTERNS
- Do NOT add ORM here - uses raw SQL throughout.
- Do NOT change handler signatures without updating router.go.
## COMMANDS
```bash
cd go-backend
go test ./internal/http/handler/...
```
@@ -114,7 +114,7 @@ func (h *Handler) ensureTunnelPermission(userID int64, roleID int, tunnelID int6
func (h *Handler) getForwardRecord(forwardID int64) (*forwardRecord, error) {
row := h.repo.DB().QueryRow(`
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, COALESCE(strategy, 'fifo'), status
FROM forward WHERE id = ? LIMIT 1
`, forwardID)
var fr forwardRecord
@@ -152,7 +152,7 @@ func (h *Handler) getTunnelRecord(tunnelID int64) (*tunnelRecord, error) {
func (h *Handler) listForwardsByTunnel(tunnelID int64) ([]forwardRecord, error) {
rows, err := h.repo.DB().Query(`
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, COALESCE(strategy, 'fifo'), status
FROM forward
WHERE tunnel_id = ?
ORDER BY id ASC
@@ -200,6 +200,26 @@ func (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error)
return result, nil
}
func (h *Handler) isTunnelSelectedTLSProtocol(tunnelID int64) (bool, error) {
row := h.repo.DB().QueryRow(`
SELECT protocol
FROM chain_tunnel
WHERE tunnel_id = ? AND chain_type = '3'
ORDER BY id ASC
LIMIT 1
`, tunnelID)
var protocol sql.NullString
if err := row.Scan(&protocol); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, err
}
return isTLSTunnelProtocol(protocol.String), nil
}
func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
row := h.repo.DB().QueryRow(`
SELECT id, name, server_ip, server_ip_v4, server_ip_v6, status, port, tcp_listen_addr, udp_listen_addr, interface_name, is_remote, remote_url, remote_token, remote_config
@@ -346,6 +366,10 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
return err
}
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, userTunnelID)
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
if err != nil {
return err
}
for _, fp := range ports {
if limiterID != nil && speed != nil {
@@ -356,7 +380,7 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
if err != nil {
return err
}
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID)
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID, tunnelTLSProtocol)
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
if err != nil && allowFallbackAdd && method == "UpdateService" {
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
@@ -457,7 +481,17 @@ func (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVa
}
func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interface{}, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {
result, err := h.wsServer.SendCommand(nodeID, commandType, data, 12*time.Second)
var (
result ws.CommandResult
err error
)
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr == nil && node != nil && node.IsRemote == 1 {
result, err = h.sendRemoteNodeCommand(node, commandType, data)
} else {
result, err = h.wsServer.SendCommand(nodeID, commandType, data, 12*time.Second)
}
if err == nil {
return result, nil
}
@@ -475,6 +509,44 @@ func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interfa
return result, err
}
func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, data interface{}) (ws.CommandResult, error) {
if node == nil {
return ws.CommandResult{}, errors.New("节点不存在")
}
remoteURL := strings.TrimSpace(node.RemoteURL)
remoteToken := strings.TrimSpace(node.RemoteToken)
if remoteURL == "" || remoteToken == "" {
return ws.CommandResult{}, errors.New("远程节点缺少共享配置")
}
fc := client.NewFederationClient()
res, err := fc.Command(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeNodeCommandRequest{
CommandType: commandType,
Data: data,
})
if err != nil {
return ws.CommandResult{}, err
}
if res == nil {
return ws.CommandResult{}, errors.New("远程节点未返回命令结果")
}
result := ws.CommandResult{
Type: res.Type,
Success: res.Success,
Message: res.Message,
Data: res.Data,
}
if !result.Success {
msg := strings.TrimSpace(result.Message)
if msg == "" {
msg = "命令执行失败"
}
return result, errors.New(msg)
}
return result, nil
}
func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]interface{}, error) {
if forward == nil {
return nil, errForwardNotFound
@@ -889,11 +961,11 @@ func firstPortFromRange(portRange string) int {
func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, error) {
rows, err := h.repo.DB().Query(`
SELECT ct.chain_type, COALESCE(ct.inx, 0), ct.node_id, COALESCE(ct.port, 0), n.name, ct.protocol, ct.strategy
SELECT CAST(ct.chain_type AS INTEGER), COALESCE(ct.inx, 0), ct.node_id, COALESCE(ct.port, 0), n.name, ct.protocol, ct.strategy
FROM chain_tunnel ct
LEFT JOIN node n ON n.id = ct.node_id
WHERE ct.tunnel_id = ?
ORDER BY ct.chain_type ASC, COALESCE(ct.inx, 0) ASC, ct.id ASC
ORDER BY CAST(ct.chain_type AS INTEGER) ASC, COALESCE(ct.inx, 0) ASC, ct.id ASC
`, tunnelID)
if err != nil {
return nil, err
@@ -1047,7 +1119,7 @@ func isNotFoundError(err error) bool {
return strings.Contains(msg, "not found") || strings.Contains(msg, "不存在")
}
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64) []map[string]interface{} {
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
protocols := []string{"tcp", "udp"}
services := make([]map[string]interface{}, 0, 2)
targets := splitRemoteTargets(forward.RemoteAddr)
@@ -1080,7 +1152,11 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
},
}
if protocol == "udp" {
service["listener"].(map[string]interface{})["metadata"] = map[string]interface{}{"keepAlive": true}
listenerMetadata := map[string]interface{}{"keepAlive": true}
if tunnelTLSProtocol {
listenerMetadata["ttl"] = "10s"
}
service["listener"].(map[string]interface{})["metadata"] = listenerMetadata
}
if tunnel != nil && tunnel.Type == 2 {
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID)
+561 -13
View File
@@ -6,7 +6,9 @@ import (
"fmt"
"net"
"net/http"
"sort"
"strings"
"sync"
"time"
"go-backend/internal/http/client"
@@ -35,6 +37,21 @@ type deletePeerShareRequest struct {
ID int64 `json:"id"`
}
type resetPeerShareFlowRequest struct {
ID int64 `json:"id"`
}
type updatePeerShareRequest struct {
ID int64 `json:"id"`
Name string `json:"name"`
MaxBandwidth int64 `json:"maxBandwidth"`
ExpiryTime int64 `json:"expiryTime"`
PortRangeStart int `json:"portRangeStart"`
PortRangeEnd int `json:"portRangeEnd"`
AllowedDomains string `json:"allowedDomains"`
AllowedIPs string `json:"allowedIps"`
}
type nodeImportRequest struct {
RemoteURL string `json:"remoteUrl"`
Token string `json:"token"`
@@ -74,6 +91,56 @@ type federationRuntimeDiagnoseRequest struct {
Timeout int `json:"timeout"`
}
type federationRuntimeCommandRequest struct {
CommandType string `json:"commandType"`
Data interface{} `json:"data"`
}
type peerShareUsedPort struct {
RuntimeID int64 `json:"runtimeId"`
Port int `json:"port"`
Role string `json:"role"`
Protocol string `json:"protocol"`
ResourceKey string `json:"resourceKey"`
Applied int `json:"applied"`
UpdatedTime int64 `json:"updatedTime"`
}
type peerShareListItem struct {
sqlite.PeerShare
UsedPorts []int `json:"usedPorts"`
UsedPortDetails []peerShareUsedPort `json:"usedPortDetails"`
ActiveRuntimeNum int `json:"activeRuntimeNum"`
}
type remoteUsageBindingItem struct {
BindingID int64 `json:"bindingId"`
TunnelID int64 `json:"tunnelId"`
TunnelName string `json:"tunnelName"`
ChainType int `json:"chainType"`
HopInx int `json:"hopInx"`
AllocatedPort int `json:"allocatedPort"`
ResourceKey string `json:"resourceKey"`
RemoteBindingID string `json:"remoteBindingId"`
UpdatedTime int64 `json:"updatedTime"`
}
type remoteUsageNodeItem struct {
NodeID int64 `json:"nodeId"`
NodeName string `json:"nodeName"`
RemoteURL string `json:"remoteUrl"`
ShareID int64 `json:"shareId"`
PortRangeStart int `json:"portRangeStart"`
PortRangeEnd int `json:"portRangeEnd"`
MaxBandwidth int64 `json:"maxBandwidth"`
CurrentFlow int64 `json:"currentFlow"`
ExpiryTime int64 `json:"expiryTime"`
UsedPorts []int `json:"usedPorts"`
Bindings []remoteUsageBindingItem `json:"bindings"`
ActiveBindingNum int `json:"activeBindingNum"`
SyncError string `json:"syncError,omitempty"`
}
func (h *Handler) federationShareList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
@@ -85,7 +152,55 @@ func (h *Handler) federationShareList(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(shares))
items := make([]peerShareListItem, 0, len(shares))
for i := range shares {
share := shares[i]
runtimes, err := h.repo.ListActivePeerShareRuntimesByShareID(share.ID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
usedSet := make(map[int]struct{}, len(runtimes))
details := make([]peerShareUsedPort, 0, len(runtimes))
for _, runtime := range runtimes {
if runtime.Port > 0 {
usedSet[runtime.Port] = struct{}{}
}
details = append(details, peerShareUsedPort{
RuntimeID: runtime.ID,
Port: runtime.Port,
Role: runtime.Role,
Protocol: runtime.Protocol,
ResourceKey: runtime.ResourceKey,
Applied: runtime.Applied,
UpdatedTime: runtime.UpdatedTime,
})
}
usedPorts := make([]int, 0, len(usedSet))
for port := range usedSet {
usedPorts = append(usedPorts, port)
}
sort.Ints(usedPorts)
sort.Slice(details, func(i, j int) bool {
if details[i].Port == details[j].Port {
return details[i].RuntimeID < details[j].RuntimeID
}
return details[i].Port < details[j].Port
})
items = append(items, peerShareListItem{
PeerShare: share,
UsedPorts: usedPorts,
UsedPortDetails: details,
ActiveRuntimeNum: len(details),
})
}
response.WriteJSON(w, response.OK(items))
}
func (h *Handler) federationShareCreate(w http.ResponseWriter, r *http.Request) {
@@ -183,6 +298,8 @@ func (h *Handler) federationShareDelete(w http.ResponseWriter, r *http.Request)
return
}
h.cleanupPeerShareRuntimes(req.ID)
if err := h.repo.DeletePeerShare(req.ID); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -191,6 +308,261 @@ func (h *Handler) federationShareDelete(w http.ResponseWriter, r *http.Request)
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) federationShareResetFlow(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
return
}
var req resetPeerShareFlowRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
return
}
if req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("Share ID is required"))
return
}
share, err := h.repo.GetPeerShare(req.ID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if share == nil {
response.WriteJSON(w, response.ErrDefault("Share not found"))
return
}
if err := h.repo.ResetPeerShareCurrentFlow(req.ID, time.Now().UnixMilli()); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) federationShareUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
return
}
var req updatePeerShareRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
return
}
if req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("Share ID is required"))
return
}
share, err := h.repo.GetPeerShare(req.ID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if share == nil {
response.WriteJSON(w, response.ErrDefault("Share not found"))
return
}
if req.Name == "" {
response.WriteJSON(w, response.ErrDefault("Name is required"))
return
}
if req.MaxBandwidth < 0 {
response.WriteJSON(w, response.ErrDefault("Max bandwidth cannot be negative"))
return
}
if req.ExpiryTime < 0 {
response.WriteJSON(w, response.ErrDefault("Expiry time cannot be negative"))
return
}
if req.PortRangeStart < 0 || req.PortRangeStart > 65535 || req.PortRangeEnd < 0 || req.PortRangeEnd > 65535 {
response.WriteJSON(w, response.ErrDefault("Invalid port range"))
return
}
if req.PortRangeStart > req.PortRangeEnd {
response.WriteJSON(w, response.ErrDefault("Port range start cannot be greater than end"))
return
}
allowedIPs, err := normalizePeerShareAllowedIPs(req.AllowedIPs)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
share.Name = req.Name
share.MaxBandwidth = req.MaxBandwidth
share.ExpiryTime = req.ExpiryTime
share.PortRangeStart = req.PortRangeStart
share.PortRangeEnd = req.PortRangeEnd
share.AllowedDomains = req.AllowedDomains
share.AllowedIPs = allowedIPs
share.UpdatedTime = time.Now().UnixMilli()
if err := h.repo.UpdatePeerShare(share); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
return
}
rows, err := h.repo.DB().Query(`
SELECT id, name, remote_url, remote_token, remote_config
FROM node
WHERE is_remote = 1
ORDER BY id DESC
`)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
defer rows.Close()
fc := client.NewFederationClient()
localDomain := h.federationLocalDomain()
items := make([]remoteUsageNodeItem, 0)
for rows.Next() {
var (
nodeID int64
nodeName string
remoteURL sql.NullString
remoteToken sql.NullString
remoteConfig sql.NullString
)
if err := rows.Scan(&nodeID, &nodeName, &remoteURL, &remoteToken, &remoteConfig); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
shareID, maxBandwidth, currentFlow, expiryTime, portRangeStart, portRangeEnd := parseRemoteShareUsageConfig(remoteConfig.String)
var syncError string
url := strings.TrimSpace(remoteURL.String)
token := strings.TrimSpace(remoteToken.String)
if url != "" && token != "" {
info, connectErr := fc.Connect(url, token, localDomain)
if connectErr != nil {
syncError = connectErr.Error()
} else if info != nil {
shareID = info.ShareID
maxBandwidth = info.MaxBandwidth
currentFlow = info.CurrentFlow
expiryTime = info.ExpiryTime
portRangeStart = info.PortRangeStart
portRangeEnd = info.PortRangeEnd
configData, _ := json.Marshal(map[string]interface{}{
"shareId": info.ShareID,
"maxBandwidth": info.MaxBandwidth,
"currentFlow": info.CurrentFlow,
"expiryTime": info.ExpiryTime,
"portRangeStart": info.PortRangeStart,
"portRangeEnd": info.PortRangeEnd,
})
_, _ = h.repo.DB().Exec(`UPDATE node SET remote_config = ? WHERE id = ?`, string(configData), nodeID)
}
}
bindingRows, err := h.repo.DB().Query(`
SELECT fb.id, fb.tunnel_id, COALESCE(t.name, ''), fb.chain_type, fb.hop_inx, fb.allocated_port, fb.resource_key, fb.remote_binding_id, fb.updated_time
FROM federation_tunnel_binding fb
LEFT JOIN tunnel t ON t.id = fb.tunnel_id
WHERE fb.node_id = ? AND fb.status = 1
ORDER BY fb.allocated_port ASC, fb.id ASC
`, nodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
usedSet := make(map[int]struct{})
bindings := make([]remoteUsageBindingItem, 0)
for bindingRows.Next() {
var item remoteUsageBindingItem
if err := bindingRows.Scan(&item.BindingID, &item.TunnelID, &item.TunnelName, &item.ChainType, &item.HopInx, &item.AllocatedPort, &item.ResourceKey, &item.RemoteBindingID, &item.UpdatedTime); err != nil {
_ = bindingRows.Close()
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
bindings = append(bindings, item)
if item.AllocatedPort > 0 {
usedSet[item.AllocatedPort] = struct{}{}
}
}
if err := bindingRows.Err(); err != nil {
_ = bindingRows.Close()
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
_ = bindingRows.Close()
usedPorts := make([]int, 0, len(usedSet))
for port := range usedSet {
usedPorts = append(usedPorts, port)
}
sort.Ints(usedPorts)
items = append(items, remoteUsageNodeItem{
NodeID: nodeID,
NodeName: nodeName,
RemoteURL: url,
ShareID: shareID,
PortRangeStart: portRangeStart,
PortRangeEnd: portRangeEnd,
MaxBandwidth: maxBandwidth,
CurrentFlow: currentFlow,
ExpiryTime: expiryTime,
UsedPorts: usedPorts,
Bindings: bindings,
ActiveBindingNum: len(bindings),
SyncError: syncError,
})
}
if err := rows.Err(); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(items))
}
func parseRemoteShareUsageConfig(raw string) (int64, int64, int64, int64, int, int) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, 0, 0, 0, 0, 0
}
var cfg map[string]interface{}
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return 0, 0, 0, 0, 0, 0
}
shareID := asInt64(cfg["shareId"], 0)
maxBandwidth := asInt64(cfg["maxBandwidth"], 0)
currentFlow := asInt64(cfg["currentFlow"], 0)
expiryTime := asInt64(cfg["expiryTime"], 0)
portRangeStart := int(asInt64(cfg["portRangeStart"], 0))
portRangeEnd := int(asInt64(cfg["portRangeEnd"], 0))
return shareID, maxBandwidth, currentFlow, expiryTime, portRangeStart, portRangeEnd
}
func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
@@ -225,6 +597,7 @@ func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
configData := map[string]interface{}{
"shareId": info.ShareID,
"maxBandwidth": info.MaxBandwidth,
"currentFlow": info.CurrentFlow,
"expiryTime": info.ExpiryTime,
"portRangeStart": info.PortRangeStart,
"portRangeEnd": info.PortRangeEnd,
@@ -370,6 +743,7 @@ func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
"serverIp": serverIP,
"status": status,
"maxBandwidth": share.MaxBandwidth,
"currentFlow": share.CurrentFlow,
"expiryTime": share.ExpiryTime,
"portRangeStart": share.PortRangeStart,
"portRangeEnd": share.PortRangeEnd,
@@ -388,6 +762,10 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
response.WriteJSON(w, response.Err(401, "Unauthorized"))
return
}
if isPeerShareFlowExceeded(share) {
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
return
}
var req federationTunnelRequest
if err := decodeJSON(r.Body, &req); err != nil {
@@ -413,7 +791,7 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
defer tx.Rollback()
now := time.Now().UnixMilli()
res, err := tx.Exec(`INSERT INTO tunnel (name, type, protocol, flow, created_time, updated_time, status, in_ip) VALUES (?, ?, ?, 0, ?, ?, 1, ?)`,
tunnelID, err := tx.ExecReturningID(`INSERT INTO tunnel (name, type, protocol, flow, created_time, updated_time, status, in_ip) VALUES (?, ?, ?, 0, ?, ?, 1, ?)`,
fmt.Sprintf("Share-%d-Port-%d", share.ID, req.RemotePort),
tunnelType,
req.Protocol,
@@ -426,9 +804,7 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
return
}
tunnelID, _ := res.LastInsertId()
_, err = tx.Exec(`INSERT INTO chain_tunnel (tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES (?, 1, ?, ?, 'fifo', 0, ?)`,
_, err = tx.Exec(`INSERT INTO chain_tunnel (tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES (?, '1', ?, ?, 'fifo', 0, ?)`,
tunnelID,
share.NodeID,
req.RemotePort,
@@ -488,6 +864,10 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
}))
return
}
if isPeerShareFlowExceeded(share) {
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
return
}
allocatedPort, err := h.pickPeerSharePort(share, req.RequestedPort)
if err != nil {
@@ -597,6 +977,10 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
}))
return
}
if isPeerShareFlowExceeded(share) {
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
return
}
node, err := h.getNodeRecord(share.NodeID)
if err != nil {
@@ -621,14 +1005,19 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
response.WriteJSON(w, response.ErrDefault("Invalid target"))
return
}
targetProtocol := defaultString(target.Protocol, protocol)
connector := map[string]interface{}{
"type": "relay",
}
if isTLSTunnelProtocol(targetProtocol) {
connector["metadata"] = map[string]interface{}{"nodelay": true}
}
nodeItems = append(nodeItems, map[string]interface{}{
"name": fmt.Sprintf("node_%d", i+1),
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
"connector": map[string]interface{}{
"type": "relay",
},
"name": fmt.Sprintf("node_%d", i+1),
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
"connector": connector,
"dialer": map[string]interface{}{
"type": defaultString(target.Protocol, protocol),
"type": targetProtocol,
},
})
}
@@ -667,6 +1056,9 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
"type": protocol,
},
}
if isTLSTunnelProtocol(protocol) {
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{"nodelay": true}
}
if req.Role == "middle" {
service["handler"].(map[string]interface{})["chain"] = chainName
}
@@ -812,6 +1204,51 @@ func (h *Handler) federationRuntimeDiagnose(w http.ResponseWriter, r *http.Reque
response.WriteJSON(w, response.OK(res.Data))
}
func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
return
}
token := extractBearerToken(r)
share, err := h.repo.GetPeerShareByToken(token)
if err != nil || share == nil {
response.WriteJSON(w, response.Err(401, "Unauthorized"))
return
}
var req federationRuntimeCommandRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
return
}
cmd := strings.TrimSpace(req.CommandType)
if cmd == "" {
response.WriteJSON(w, response.ErrDefault("commandType is required"))
return
}
if !isFederationRuntimeCommandAllowed(cmd) {
response.WriteJSON(w, response.ErrDefault("command not allowed"))
return
}
res, err := h.sendNodeCommand(share.NodeID, cmd, req.Data, false, false)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.OK(res))
}
func isFederationRuntimeCommandAllowed(commandType string) bool {
switch strings.ToLower(strings.TrimSpace(commandType)) {
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "deletelimiters", "tcpping", "reload":
return true
default:
return false
}
}
func (h *Handler) pickPeerSharePort(share *sqlite.PeerShare, requestedPort int) (int, error) {
if share == nil {
return 0, fmt.Errorf("share not found")
@@ -885,6 +1322,16 @@ func extractBearerToken(r *http.Request) string {
return ""
}
func isPeerShareFlowExceeded(share *sqlite.PeerShare) bool {
if share == nil {
return false
}
if share.MaxBandwidth <= 0 {
return false
}
return share.CurrentFlow >= share.MaxBandwidth
}
func normalizePeerShareAllowedIPs(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
@@ -959,7 +1406,7 @@ func parseIPLiteral(raw string) net.IP {
}
if ip := net.ParseIP(value); ip != nil {
return ip
return normalizeIPAddress(ip)
}
host, _, err := net.SplitHostPort(value)
@@ -971,7 +1418,17 @@ func parseIPLiteral(raw string) net.IP {
if host == "" {
return nil
}
return net.ParseIP(host)
return normalizeIPAddress(net.ParseIP(host))
}
func normalizeIPAddress(ip net.IP) net.IP {
if ip == nil {
return nil
}
if v4 := ip.To4(); v4 != nil {
return v4
}
return ip.To16()
}
func isTrustedProxyIP(ip net.IP) bool {
@@ -1011,3 +1468,94 @@ func isPeerIPAllowed(clientIP net.IP, whitelist string) bool {
return false
}
func (h *Handler) syncRemoteNodeStatuses(items []map[string]interface{}) {
type remoteEntry struct {
index int
remoteURL string
remoteToken string
}
var remotes []remoteEntry
for i, item := range items {
isRemote, _ := item["isRemote"].(int)
if isRemote != 1 {
continue
}
url, _ := item["remoteUrl"].(string)
token, _ := item["remoteToken"].(string)
url = strings.TrimSpace(url)
token = strings.TrimSpace(token)
if url == "" || token == "" {
continue
}
remotes = append(remotes, remoteEntry{index: i, remoteURL: url, remoteToken: token})
}
if len(remotes) == 0 {
return
}
localDomain := h.federationLocalDomain()
fc := client.NewFederationClientWithTimeout(5 * time.Second)
type syncResult struct {
index int
status int
syncError string
}
results := make([]syncResult, len(remotes))
var wg sync.WaitGroup
for i, entry := range remotes {
wg.Add(1)
go func(idx int, e remoteEntry) {
defer wg.Done()
info, err := fc.Connect(e.remoteURL, e.remoteToken, localDomain)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "401") || strings.Contains(errMsg, "Invalid token") || strings.Contains(errMsg, "Unauthorized") {
results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_deleted"}
} else if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "Share is disabled") {
results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_disabled"}
} else if strings.Contains(errMsg, "Share expired") {
results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_expired"}
} else {
results[idx] = syncResult{index: e.index, status: 0, syncError: errMsg}
}
} else {
results[idx] = syncResult{index: e.index, status: info.Status, syncError: ""}
}
}(i, entry)
}
wg.Wait()
for _, r := range results {
items[r.index]["status"] = r.status
if r.syncError != "" {
items[r.index]["syncError"] = r.syncError
}
}
}
func (h *Handler) cleanupPeerShareRuntimes(shareID int64) {
if h == nil || h.repo == nil || shareID <= 0 {
return
}
runtimes, err := h.repo.ListActivePeerShareRuntimesByShareID(shareID)
if err != nil || len(runtimes) == 0 {
return
}
now := time.Now().UnixMilli()
for _, runtime := range runtimes {
if h.wsServer != nil && runtime.Applied == 1 {
if strings.TrimSpace(runtime.ServiceName) != "" {
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteService", map[string]interface{}{"services": []string{runtime.ServiceName}}, false, true)
}
if strings.TrimSpace(runtime.Role) == "middle" && strings.TrimSpace(runtime.ChainName) != "" {
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true)
}
}
_ = h.repo.MarkPeerShareRuntimeReleased(runtime.ID, now)
}
}
@@ -1,10 +1,15 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"go-backend/internal/http/response"
"go-backend/internal/store/sqlite"
)
@@ -146,3 +151,125 @@ func TestPrepareTunnelCreateStateRemoteAutoPortDefersToFederation(t *testing.T)
t.Fatalf("expected remote out port to remain 0 before federation reserve, got %d", state.OutNodes[0].Port)
}
}
func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer repo.Close()
h := &Handler{repo: repo}
now := time.Now().UnixMilli()
insertNode := func(name string, status int, portRange string, isRemote int) int64 {
res, execErr := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":2}`)
if execErr != nil {
t.Fatalf("insert node %s: %v", name, execErr)
}
id, idErr := res.LastInsertId()
if idErr != nil {
t.Fatalf("node id %s: %v", name, idErr)
}
return id
}
entryID := insertNode("entry-local", 1, "32000-32010", 0)
remoteMiddleID := insertNode("middle-remote", 0, "33000-33010", 1)
outID := insertNode("out-local", 1, "34000-34010", 0)
tx, err := repo.DB().Begin()
if err != nil {
t.Fatalf("begin tx: %v", err)
}
defer tx.Rollback()
req := map[string]interface{}{
"name": "remote-middle-offline-status",
"inNodeId": []interface{}{
map[string]interface{}{"nodeId": float64(entryID), "protocol": "tls", "strategy": "round"},
},
"chainNodes": []interface{}{
[]interface{}{
map[string]interface{}{"nodeId": float64(remoteMiddleID), "protocol": "tls", "strategy": "round", "port": float64(0)},
},
},
"outNodeId": []interface{}{
map[string]interface{}{"nodeId": float64(outID), "protocol": "tls", "strategy": "round", "port": float64(0)},
},
}
state, err := h.prepareTunnelCreateState(tx, req, 2, 0)
if err != nil {
t.Fatalf("prepare state should allow offline remote middle node: %v", err)
}
if len(state.ChainHops) != 1 || len(state.ChainHops[0]) != 1 {
t.Fatalf("expected one middle hop node, got %+v", state.ChainHops)
}
if state.ChainHops[0][0].NodeID != remoteMiddleID {
t.Fatalf("expected remote middle node id %d, got %d", remoteMiddleID, state.ChainHops[0][0].NodeID)
}
if state.Nodes[remoteMiddleID] == nil || state.Nodes[remoteMiddleID].IsRemote != 1 {
t.Fatalf("expected remote middle node metadata in state")
}
}
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer repo.Close()
h := &Handler{repo: repo}
now := time.Now().UnixMilli()
if err := repo.CreatePeerShare(&sqlite.PeerShare{
Name: "limited-share",
NodeID: 1,
Token: "limited-token",
MaxBandwidth: 2048,
CurrentFlow: 2048,
PortRangeStart: 30000,
PortRangeEnd: 30010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
body, err := json.Marshal(map[string]interface{}{
"resourceKey": "tunnel:1:node:1:type:3:hop:0",
"protocol": "tls",
"requestedPort": 0,
})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/reserve-port", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer limited-token")
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
h.federationRuntimeReservePort(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Code != 403 {
t.Fatalf("expected response code 403, got %d (%s)", payload.Code, payload.Msg)
}
if payload.Msg != "Share traffic limit exceeded" {
t.Fatalf("unexpected response message: %q", payload.Msg)
}
}
@@ -144,6 +144,393 @@ func TestFederationShareCreateRejectsInvalidAllowedIPs(t *testing.T) {
}
}
func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
h := New(repo, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := repo.CreatePeerShare(&sqlite.PeerShare{
Name: "provider-share",
NodeID: 9,
Token: "share-list-token",
MaxBandwidth: 1024,
CurrentFlow: 512,
PortRangeStart: 22000,
PortRangeEnd: 22010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := repo.GetPeerShareByToken("share-list-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
share.ID, share.NodeID, "r-1", "rk-1", "b-1", "middle", "fed_chain_1", "fed_svc_1", "tls", "round", 22001, "", 1, 1, now, now,
share.ID, share.NodeID, "r-2", "rk-2", "b-2", "exit", "", "fed_svc_2", "tls", "round", 22002, "", 1, 1, now, now,
share.ID, share.NodeID, "r-3", "rk-3", "", "", "", "", "tls", "round", 22003, "", 0, 0, now, now,
); err != nil {
t.Fatalf("insert peer_share_runtime rows: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/list", nil)
res := httptest.NewRecorder()
h.federationShareList(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Code != 0 {
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
}
rows, ok := payload.Data.([]interface{})
if !ok || len(rows) == 0 {
t.Fatalf("expected non-empty share list, got %T", payload.Data)
}
first, ok := rows[0].(map[string]interface{})
if !ok {
t.Fatalf("expected share row object, got %T", rows[0])
}
if int(first["activeRuntimeNum"].(float64)) != 2 {
t.Fatalf("expected activeRuntimeNum=2, got %v", first["activeRuntimeNum"])
}
usedPortsRaw, ok := first["usedPorts"].([]interface{})
if !ok {
t.Fatalf("expected usedPorts array, got %T", first["usedPorts"])
}
if len(usedPortsRaw) != 2 {
t.Fatalf("expected 2 used ports, got %d", len(usedPortsRaw))
}
if int(usedPortsRaw[0].(float64)) != 22001 || int(usedPortsRaw[1].(float64)) != 22002 {
t.Fatalf("unexpected used ports payload: %v", usedPortsRaw)
}
detailsRaw, ok := first["usedPortDetails"].([]interface{})
if !ok {
t.Fatalf("expected usedPortDetails array, got %T", first["usedPortDetails"])
}
if len(detailsRaw) != 2 {
t.Fatalf("expected 2 usedPortDetails rows, got %d", len(detailsRaw))
}
}
func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
h := New(repo, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := repo.CreatePeerShare(&sqlite.PeerShare{
Name: "delete-cleanup-share",
NodeID: 99,
Token: "delete-cleanup-token",
MaxBandwidth: 4096,
PortRangeStart: 40000,
PortRangeEnd: 40010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := repo.GetPeerShareByToken("delete-cleanup-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
share.ID, 99, "dc-r1", "dc-rk1", "dc-b1", "exit", "", "fed_svc_dc1", "tls", "round", 40001, "", 1, 1, now, now,
share.ID, 99, "dc-r2", "dc-rk2", "dc-b2", "middle", "fed_chain_dc2", "fed_svc_dc2", "tls", "round", 40002, "", 1, 1, now, now,
); err != nil {
t.Fatalf("insert peer_share_runtime rows: %v", err)
}
var runtimeCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1`, share.ID).Scan(&runtimeCount); err != nil {
t.Fatalf("count active runtimes before: %v", err)
}
if runtimeCount != 2 {
t.Fatalf("expected 2 active runtimes before delete, got %d", runtimeCount)
}
body, err := json.Marshal(deletePeerShareRequest{ID: share.ID})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/delete", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
h.federationShareDelete(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Code != 0 {
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
}
var shareCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share WHERE id = ?`, share.ID).Scan(&shareCount); err != nil {
t.Fatalf("count peer_share after: %v", err)
}
if shareCount != 0 {
t.Fatalf("expected peer_share deleted, got %d rows", shareCount)
}
var runtimeCountAfter int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, share.ID).Scan(&runtimeCountAfter); err != nil {
t.Fatalf("count peer_share_runtime after: %v", err)
}
if runtimeCountAfter != 0 {
t.Fatalf("expected all peer_share_runtime rows deleted, got %d", runtimeCountAfter)
}
}
func TestFederationRemoteUsageListSyncErrorFallback(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
h := New(repo, "test-jwt-secret")
now := time.Now().UnixMilli()
if _, err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "sync-error-node", "sync-error-secret", "10.50.60.70", "10.50.60.70", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://unreachable.invalid:9999", "bad-token", `{"shareId":42,"maxBandwidth":5368709120,"currentFlow":999999,"portRangeStart":32000,"portRangeEnd":32010}`); err != nil {
t.Fatalf("insert remote node: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/remote-usage/list", nil)
res := httptest.NewRecorder()
h.federationRemoteUsageList(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Code != 0 {
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
}
rows, ok := payload.Data.([]interface{})
if !ok || len(rows) == 0 {
t.Fatalf("expected non-empty usage list, got %T", payload.Data)
}
first, ok := rows[0].(map[string]interface{})
if !ok {
t.Fatalf("expected row map, got %T", rows[0])
}
if int64(first["shareId"].(float64)) != 42 {
t.Fatalf("expected stale shareId=42 on sync failure, got %v", first["shareId"])
}
if int64(first["currentFlow"].(float64)) != 999999 {
t.Fatalf("expected stale currentFlow=999999 on sync failure, got %v", first["currentFlow"])
}
syncErr, _ := first["syncError"].(string)
if syncErr == "" {
t.Fatalf("expected non-empty syncError field on unreachable provider")
}
}
func TestFederationShareResetFlow(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
h := New(repo, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := repo.CreatePeerShare(&sqlite.PeerShare{
Name: "reset-flow-share",
NodeID: 11,
Token: "reset-flow-token",
MaxBandwidth: 4096,
CurrentFlow: 2048,
PortRangeStart: 23000,
PortRangeEnd: 23010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := repo.GetPeerShareByToken("reset-flow-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
body, err := json.Marshal(resetPeerShareFlowRequest{ID: share.ID})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/reset-flow", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
h.federationShareResetFlow(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Code != 0 {
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
}
updated, err := repo.GetPeerShare(share.ID)
if err != nil || updated == nil {
t.Fatalf("reload peer share: %v", err)
}
if updated.CurrentFlow != 0 {
t.Fatalf("expected current flow reset to 0, got %d", updated.CurrentFlow)
}
}
func TestFederationRemoteUsageList(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
h := New(repo, "test-jwt-secret")
now := time.Now().UnixMilli()
resNode, err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`)
if err != nil {
t.Fatalf("insert remote node: %v", err)
}
nodeID, err := resNode.LastInsertId()
if err != nil {
t.Fatalf("remote node id: %v", err)
}
resTunnelA, err := repo.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-a", 2, "tls", 1, now, now, 1, "", 0)
if err != nil {
t.Fatalf("insert tunnel a: %v", err)
}
tunnelAID, _ := resTunnelA.LastInsertId()
resTunnelB, err := repo.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-b", 2, "tls", 1, now, now, 1, "", 0)
if err != nil {
t.Fatalf("insert tunnel b: %v", err)
}
tunnelBID, _ := resTunnelB.LastInsertId()
if _, err := repo.DB().Exec(`
INSERT INTO federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx, remote_url, resource_key, remote_binding_id, allocated_port, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
tunnelAID, nodeID, 2, 1, "http://peer.example", "rk-a", "rb-a", 31001, 1, now, now,
tunnelBID, nodeID, 3, 0, "http://peer.example", "rk-b", "rb-b", 31002, 1, now, now,
); err != nil {
t.Fatalf("insert federation bindings: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/remote-usage/list", nil)
res := httptest.NewRecorder()
h.federationRemoteUsageList(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Code != 0 {
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
}
rows, ok := payload.Data.([]interface{})
if !ok || len(rows) == 0 {
t.Fatalf("expected non-empty usage list, got %T", payload.Data)
}
first, ok := rows[0].(map[string]interface{})
if !ok {
t.Fatalf("expected first usage row map, got %T", rows[0])
}
if int64(first["shareId"].(float64)) != 88 {
t.Fatalf("expected shareId=88, got %v", first["shareId"])
}
usedPortsRaw, ok := first["usedPorts"].([]interface{})
if !ok {
t.Fatalf("expected usedPorts array, got %T", first["usedPorts"])
}
if len(usedPortsRaw) != 2 {
t.Fatalf("expected 2 used ports, got %d", len(usedPortsRaw))
}
if int(usedPortsRaw[0].(float64)) != 31001 || int(usedPortsRaw[1].(float64)) != 31002 {
t.Fatalf("unexpected used ports payload: %v", usedPortsRaw)
}
bindingsRaw, ok := first["bindings"].([]interface{})
if !ok {
t.Fatalf("expected bindings array, got %T", first["bindings"])
}
if len(bindingsRaw) != 2 {
t.Fatalf("expected 2 binding rows, got %d", len(bindingsRaw))
}
}
func TestAuthPeerAllowedIPs(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
@@ -180,6 +567,13 @@ func TestAuthPeerAllowedIPs(t *testing.T) {
xff: "198.51.100.20, 172.20.0.3",
wantAllowed: true,
},
{
name: "ipv4-mapped proxy xff allowed",
allowedIPs: "198.51.100.20",
remoteAddr: "[::ffff:172.20.0.3]:34567",
xff: "198.51.100.20, 172.20.0.3",
wantAllowed: true,
},
{
name: "non whitelisted ip denied",
allowedIPs: "203.0.113.10",
@@ -38,16 +38,21 @@ func (h *Handler) processFlowItem(item flowItem) {
}
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
if !ok {
if ok {
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
if userTunnelID > 0 {
h.enforceFlowPolicies(userID, userTunnelID)
}
return
}
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
if userTunnelID > 0 {
h.enforceFlowPolicies(userID, userTunnelID)
runtimeID, ok := parsePeerShareRuntimeServiceID(serviceName)
if !ok {
return
}
h.processPeerShareFlow(runtimeID, item)
}
func parseFlowServiceIDs(serviceName string) (int64, int64, int64, bool) {
@@ -66,6 +71,72 @@ func parseFlowServiceIDs(serviceName string) (int64, int64, int64, bool) {
return forwardID, userID, userTunnelID, true
}
func parsePeerShareRuntimeServiceID(serviceName string) (int64, bool) {
const prefix = "fed_svc_"
if !strings.HasPrefix(serviceName, prefix) {
return 0, false
}
raw := strings.TrimPrefix(serviceName, prefix)
if raw == "" {
return 0, false
}
parts := strings.SplitN(raw, "_", 2)
runtimeID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || runtimeID <= 0 {
return 0, false
}
return runtimeID, true
}
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
if h == nil || h.repo == nil || runtimeID <= 0 {
return
}
runtime, err := h.repo.GetPeerShareRuntimeByID(runtimeID)
if err != nil || runtime == nil || runtime.ShareID <= 0 || runtime.Status != 1 {
return
}
delta := item.D + item.U
if delta <= 0 {
return
}
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
share, err := h.repo.GetPeerShare(runtime.ShareID)
if err != nil || share == nil {
return
}
if !isPeerShareFlowExceeded(share) {
return
}
h.enforcePeerShareFlowLimit(share.ID)
}
func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
if h == nil || h.repo == nil || shareID <= 0 {
return
}
runtimes, err := h.repo.ListActivePeerShareRuntimesByShareID(shareID)
if err != nil || len(runtimes) == 0 {
return
}
now := time.Now().UnixMilli()
for _, runtime := range runtimes {
if h.wsServer != nil && runtime.Applied == 1 {
if strings.TrimSpace(runtime.ServiceName) != "" {
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteService", map[string]interface{}{"services": []string{runtime.ServiceName}}, false, true)
}
if strings.TrimSpace(runtime.Role) == "middle" && strings.TrimSpace(runtime.ChainName) != "" {
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true)
}
}
_ = h.repo.MarkPeerShareRuntimeReleased(runtime.ID, now)
}
}
func (h *Handler) scaleFlowByTunnel(forwardID int64, inFlow int64, outFlow int64) (int64, int64) {
forward, err := h.getForwardRecord(forwardID)
if err != nil || forward == nil {
@@ -180,7 +251,7 @@ func (h *Handler) pauseForwardRecords(forwards []forwardRecord, now int64) {
func (h *Handler) listActiveForwardsByUser(userID int64) ([]forwardRecord, error) {
rows, err := h.repo.DB().Query(`
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, COALESCE(strategy, 'fifo'), status
FROM forward
WHERE user_id = ? AND status = 1
ORDER BY id ASC
@@ -195,7 +266,7 @@ func (h *Handler) listActiveForwardsByUser(userID int64) ([]forwardRecord, error
func (h *Handler) listActiveForwardsByUserTunnel(userID int64, tunnelID int64) ([]forwardRecord, error) {
rows, err := h.repo.DB().Query(`
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, COALESCE(strategy, 'fifo'), status
FROM forward
WHERE user_id = ? AND tunnel_id = ? AND status = 1
ORDER BY id ASC
@@ -0,0 +1,63 @@
package handler
import (
"path/filepath"
"testing"
"time"
"go-backend/internal/store/sqlite"
)
func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer repo.Close()
now := time.Now().UnixMilli()
if err := repo.CreatePeerShare(&sqlite.PeerShare{
Name: "flow-share",
NodeID: 1,
Token: "flow-share-token",
MaxBandwidth: 3000,
CurrentFlow: 1000,
PortRangeStart: 32000,
PortRangeEnd: 32010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := repo.GetPeerShareByToken("flow-share-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 17, share.ID, share.NodeID, "res-17", "rk-17", "17", "exit", "", "fed_svc_17", "tls", "round", 32001, "", 1, 1, now, now); err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
h := &Handler{repo: repo}
h.processFlowItem(flowItem{N: "fed_svc_17", U: 1200, D: 900})
updatedShare, err := repo.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
t.Fatalf("reload share: %v", err)
}
if updatedShare.CurrentFlow != 3100 {
t.Fatalf("expected current_flow=3100, got %d", updatedShare.CurrentFlow)
}
runtime, err := repo.GetPeerShareRuntimeByID(17)
if err != nil || runtime == nil {
t.Fatalf("reload runtime: %v", err)
}
if runtime.Status != 0 {
t.Fatalf("expected runtime status=0 after limit enforcement, got %d", runtime.Status)
}
}
+122
View File
@@ -93,6 +93,12 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
mux.HandleFunc("/api/v1/backup/restore", h.backupImport)
mux.HandleFunc("/api/v1/api/v1/backup/export", h.backupExport)
mux.HandleFunc("/api/v1/api/v1/backup/import", h.backupImport)
mux.HandleFunc("/api/v1/api/v1/backup/restore", h.backupImport)
mux.HandleFunc("/api/v1/captcha/check", h.checkCaptcha)
mux.HandleFunc("/api/v1/captcha/verify", h.captchaVerify)
mux.HandleFunc("/api/v1/user/package", h.userPackage)
@@ -105,6 +111,10 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/node/update-order", h.nodeUpdateOrder)
mux.HandleFunc("/api/v1/node/batch-delete", h.nodeBatchDelete)
mux.HandleFunc("/api/v1/node/check-status", h.nodeCheckStatus)
mux.HandleFunc("/api/v1/node/upgrade", h.nodeUpgrade)
mux.HandleFunc("/api/v1/node/batch-upgrade", h.nodeBatchUpgrade)
mux.HandleFunc("/api/v1/node/rollback", h.nodeRollback)
mux.HandleFunc("/api/v1/node/releases", h.listReleases)
mux.HandleFunc("/api/v1/tunnel/list", h.tunnelList)
mux.HandleFunc("/api/v1/tunnel/create", h.tunnelCreate)
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
@@ -155,13 +165,17 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/open_api/sub_store", h.openAPISubStore)
mux.HandleFunc("/api/v1/federation/share/list", h.federationShareList)
mux.HandleFunc("/api/v1/federation/share/create", h.federationShareCreate)
mux.HandleFunc("/api/v1/federation/share/update", h.federationShareUpdate)
mux.HandleFunc("/api/v1/federation/share/delete", h.federationShareDelete)
mux.HandleFunc("/api/v1/federation/share/reset-flow", h.federationShareResetFlow)
mux.HandleFunc("/api/v1/federation/share/remote-usage/list", h.federationRemoteUsageList)
mux.HandleFunc("/api/v1/federation/connect", h.authPeer(h.federationConnect))
mux.HandleFunc("/api/v1/federation/tunnel/create", h.authPeer(h.federationTunnelCreate))
mux.HandleFunc("/api/v1/federation/runtime/reserve-port", h.authPeer(h.federationRuntimeReservePort))
mux.HandleFunc("/api/v1/federation/runtime/apply-role", h.authPeer(h.federationRuntimeApplyRole))
mux.HandleFunc("/api/v1/federation/runtime/release-role", h.authPeer(h.federationRuntimeReleaseRole))
mux.HandleFunc("/api/v1/federation/runtime/diagnose", h.authPeer(h.federationRuntimeDiagnose))
mux.HandleFunc("/api/v1/federation/runtime/command", h.authPeer(h.federationRuntimeCommand))
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
mux.HandleFunc("/flow/test", h.flowTest)
@@ -299,11 +313,35 @@ func (h *Handler) userList(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
Current int `json:"current"`
Size int `json:"size"`
Keyword string `json:"keyword"`
}
if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
users, err := h.repo.ListUsers()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
keyword := strings.ToLower(strings.TrimSpace(req.Keyword))
if keyword != "" {
filtered := make([]map[string]interface{}, 0, len(users))
for _, item := range users {
username := strings.ToLower(strings.TrimSpace(fmt.Sprint(item["user"])))
displayName := strings.ToLower(strings.TrimSpace(fmt.Sprint(item["name"])))
if strings.Contains(username, keyword) || strings.Contains(displayName, keyword) {
filtered = append(filtered, item)
}
}
users = filtered
}
response.WriteJSON(w, response.OK(users))
}
@@ -318,6 +356,9 @@ func (h *Handler) nodeList(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
h.syncRemoteNodeStatuses(items)
response.WriteJSON(w, response.OK(items))
}
@@ -1106,3 +1147,84 @@ func (h *Handler) verifyCloudflareTurnstile(token, secretKey string) bool {
}
return body.Success
}
type backupExportRequest struct {
Types []string `json:"types"`
}
func (h *Handler) backupExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req backupExportRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.Err(500, "请求参数错误"))
return
}
var backup interface{}
var err error
if len(req.Types) == 0 {
backup, err = h.repo.ExportAll()
} else {
backup, err = h.repo.ExportPartial(req.Types)
}
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
w.Header().Set("Content-Disposition", "attachment; filename=backup.json")
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(backup); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
}
type backupImportRequest struct {
Types []string `json:"types"`
sqlite.BackupData
}
func (h *Handler) backupImport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req backupImportRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.Err(500, "请求参数错误"))
return
}
if len(req.Types) == 0 {
response.WriteJSON(w, response.Err(500, "请选择要导入的数据类型"))
return
}
autoBackup, err := h.repo.ExportAll()
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("导入前自动备份失败: %v", err)))
return
}
if req.BackupData.Version == "" {
response.WriteJSON(w, response.Err(500, "备份数据格式错误"))
return
}
result, err := h.repo.Import(&req.BackupData, req.Types)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("导入失败: %v", err)))
return
}
result.AutoBackup = autoBackup
response.WriteJSON(w, response.OK(result))
}
+234 -47
View File
@@ -18,6 +18,7 @@ import (
"go-backend/internal/http/client"
"go-backend/internal/http/response"
"go-backend/internal/security"
"go-backend/internal/store"
"go-backend/internal/store/sqlite"
)
@@ -413,7 +414,7 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
cmd := fmt.Sprintf("curl -L https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret)
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret)
response.WriteJSON(w, response.OK(cmd))
}
@@ -559,13 +560,12 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
}
}
res, err := tx.Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
tunnelID, err := tx.ExecReturningID(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
name, trafficRatio, typeVal, "tls", flow, now, now, status, nullableText(inIP), inx)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
tunnelID, _ := res.LastInsertId()
runtimeState.TunnelID = tunnelID
var federationBindings []sqlite.FederationTunnelBinding
var federationReleaseRefs []federationRuntimeReleaseRef
@@ -688,6 +688,9 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
return
}
runtimeState.TunnelID = id
inIp := buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes)
var federationBindings []sqlite.FederationTunnelBinding
var federationReleaseRefs []federationRuntimeReleaseRef
if typeVal == 2 {
@@ -700,7 +703,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
applyTunnelPortsToRequest(req, runtimeState)
_, err = tx.Exec(`UPDATE tunnel SET name=?, type=?, flow=?, traffic_ratio=?, status=?, in_ip=?, updated_time=? WHERE id=?`,
asString(req["name"]), typeVal, asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1), nullableText(asString(req["inIp"])), now, id)
asString(req["name"]), typeVal, asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1), nullableText(inIp), now, id)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -1119,7 +1122,7 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
return
}
defer func() { _ = tx.Rollback() }()
res, err := tx.Exec(`
forwardID, err := tx.ExecReturningID(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
`, userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, now, inx)
@@ -1127,7 +1130,6 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
forwardID, _ := res.LastInsertId()
entryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
for _, nodeID := range entryNodes {
_, _ = tx.Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port)
@@ -1587,13 +1589,12 @@ func (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {
}
now := time.Now().UnixMilli()
speed := asInt(req["speed"], 100)
res, err := h.repo.DB().Exec(`INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status) VALUES(?, ?, ?, ?, ?, ?, ?)`,
id, err := h.repo.DB().ExecReturningID(`INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status) VALUES(?, ?, ?, ?, ?, ?, ?)`,
name, speed, tunnelID, tunnelName, now, now, asInt(req["status"], 1))
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
id, _ := res.LastInsertId()
_ = h.sendLimiterConfig(id, speed, tunnelID)
response.WriteJSON(w, response.OKEmpty())
}
@@ -1687,7 +1688,7 @@ func (h *Handler) groupTunnelAssign(w http.ResponseWriter, r *http.Request) {
defer func() { _ = tx.Rollback() }()
_, _ = tx.Exec(`DELETE FROM tunnel_group_tunnel WHERE tunnel_group_id = ?`, req.GroupID)
for _, tid := range req.TunnelIDs {
_, _ = tx.Exec(`INSERT OR IGNORE INTO tunnel_group_tunnel(tunnel_group_id, tunnel_id, created_time) VALUES(?, ?, ?)`, req.GroupID, tid, time.Now().UnixMilli())
_, _ = tx.Exec(`INSERT INTO tunnel_group_tunnel(tunnel_group_id, tunnel_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`, req.GroupID, tid, time.Now().UnixMilli())
}
if err := tx.Commit(); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
@@ -1712,9 +1713,18 @@ func (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {
return
}
defer func() { _ = tx.Rollback() }()
previousUserIDs, err := queryInt64ListTx(tx, `SELECT user_id FROM user_group_user WHERE user_group_id = ?`, req.GroupID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
_, _ = tx.Exec(`DELETE FROM user_group_user WHERE user_group_id = ?`, req.GroupID)
for _, uid := range req.UserIDs {
_, _ = tx.Exec(`INSERT OR IGNORE INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?)`, req.GroupID, uid, time.Now().UnixMilli())
_, _ = tx.Exec(`INSERT INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`, req.GroupID, uid, time.Now().UnixMilli())
}
if err := revokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := tx.Commit(); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
@@ -1733,7 +1743,7 @@ func (h *Handler) groupPermissionAssign(w http.ResponseWriter, r *http.Request)
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
_, err := h.repo.DB().Exec(`INSERT OR IGNORE INTO group_permission(user_group_id, tunnel_group_id, created_time) VALUES(?, ?, ?)`, req.UserGroupID, req.TunnelGroupID, time.Now().UnixMilli())
_, err := h.repo.DB().Exec(`INSERT INTO group_permission(user_group_id, tunnel_group_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`, req.UserGroupID, req.TunnelGroupID, time.Now().UnixMilli())
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -1747,10 +1757,35 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
if id <= 0 {
return
}
tx, err := h.repo.DB().Begin()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
defer func() { _ = tx.Rollback() }()
var ug, tg int64
_ = h.repo.DB().QueryRow(`SELECT user_group_id, tunnel_group_id FROM group_permission WHERE id = ?`, id).Scan(&ug, &tg)
_, _ = h.repo.DB().Exec(`DELETE FROM group_permission WHERE id = ?`, id)
_, _ = h.repo.DB().Exec(`DELETE FROM group_permission_grant WHERE user_group_id = ? AND tunnel_group_id = ?`, ug, tg)
err = tx.QueryRow(`SELECT user_group_id, tunnel_group_id FROM group_permission WHERE id = ?`, id).Scan(&ug, &tg)
if err != nil && err != sql.ErrNoRows {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if _, err := tx.Exec(`DELETE FROM group_permission WHERE id = ?`, id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err == nil {
if err := revokeGroupPermissionPairTx(tx, ug, tg); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
}
if err := tx.Commit(); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -1835,7 +1870,7 @@ func (h *Handler) applyGroupPermission(userGroupID, tunnelGroupID int64) error {
if created {
createdByGroup = 1
}
_, _ = db.Exec(`INSERT OR IGNORE INTO group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id, created_by_group, created_time) VALUES(?, ?, ?, ?, ?)`,
_, _ = db.Exec(`INSERT INTO group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id, created_by_group, created_time) VALUES(?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`,
userGroupID, tunnelGroupID, utID, createdByGroup, time.Now().UnixMilli())
}
}
@@ -1866,7 +1901,7 @@ func (h *Handler) syncPermissionsByTunnelGroup(tunnelGroupID int64) error {
return nil
}
func ensureUserTunnelGrant(db *sql.DB, userID, tunnelID int64) (int64, bool, error) {
func ensureUserTunnelGrant(db *store.DB, userID, tunnelID int64) (int64, bool, error) {
var id int64
err := db.QueryRow(`SELECT id FROM user_tunnel WHERE user_id = ? AND tunnel_id = ? LIMIT 1`, userID, tunnelID).Scan(&id)
if err == nil {
@@ -1882,16 +1917,15 @@ func ensureUserTunnelGrant(db *sql.DB, userID, tunnelID int64) (int64, bool, err
if err := db.QueryRow(`SELECT flow, num, exp_time, flow_reset_time FROM user WHERE id = ?`, userID).Scan(&flow, &num, &expTime, &flowReset); err != nil {
return 0, false, err
}
res, err := db.Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, 1)`,
id, err = db.ExecReturningID(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, 1)`,
userID, tunnelID, num, flow, flowReset, expTime)
if err != nil {
return 0, false, err
}
id, _ = res.LastInsertId()
return id, true, nil
}
func queryInt64List(db *sql.DB, q string, args ...interface{}) ([]int64, error) {
func queryInt64List(db *store.DB, q string, args ...interface{}) ([]int64, error) {
rows, err := db.Query(q, args...)
if err != nil {
return nil, err
@@ -1908,7 +1942,145 @@ func queryInt64List(db *sql.DB, q string, args ...interface{}) ([]int64, error)
return out, rows.Err()
}
func queryPairs(db *sql.DB, q string, args ...interface{}) ([][2]int64, error) {
func queryInt64ListTx(tx *store.Tx, q string, args ...interface{}) ([]int64, error) {
rows, err := tx.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]int64, 0)
for rows.Next() {
var v int64
if err := rows.Scan(&v); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func revokeGroupGrantsForRemovedUsersTx(tx *store.Tx, userGroupID int64, previousUserIDs, currentUserIDs []int64) error {
currentSet := make(map[int64]struct{}, len(currentUserIDs))
for _, uid := range currentUserIDs {
if uid > 0 {
currentSet[uid] = struct{}{}
}
}
removedUserIDs := make([]int64, 0)
for _, uid := range previousUserIDs {
if uid <= 0 {
continue
}
if _, ok := currentSet[uid]; !ok {
removedUserIDs = append(removedUserIDs, uid)
}
}
if len(removedUserIDs) == 0 {
return nil
}
for _, userID := range removedUserIDs {
rows, err := tx.Query(`
SELECT g.user_tunnel_id, g.created_by_group
FROM group_permission_grant g
JOIN user_tunnel ut ON ut.id = g.user_tunnel_id
WHERE g.user_group_id = ? AND ut.user_id = ?
`, userGroupID, userID)
if err != nil {
return err
}
groupCreatedTunnelIDs := make(map[int64]struct{})
for rows.Next() {
var userTunnelID int64
var createdByGroup int
if err := rows.Scan(&userTunnelID, &createdByGroup); err != nil {
rows.Close()
return err
}
if createdByGroup == 1 && userTunnelID > 0 {
groupCreatedTunnelIDs[userTunnelID] = struct{}{}
}
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
if _, err := tx.Exec(`
DELETE FROM group_permission_grant
WHERE user_group_id = ?
AND user_tunnel_id IN (SELECT id FROM user_tunnel WHERE user_id = ?)
`, userGroupID, userID); err != nil {
return err
}
for userTunnelID := range groupCreatedTunnelIDs {
var remaining int
if err := tx.QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&remaining); err != nil {
return err
}
if remaining == 0 {
if _, err := tx.Exec(`DELETE FROM user_tunnel WHERE id = ?`, userTunnelID); err != nil {
return err
}
}
}
}
return nil
}
func revokeGroupPermissionPairTx(tx *store.Tx, userGroupID, tunnelGroupID int64) error {
rows, err := tx.Query(`
SELECT user_tunnel_id, created_by_group
FROM group_permission_grant
WHERE user_group_id = ? AND tunnel_group_id = ?
`, userGroupID, tunnelGroupID)
if err != nil {
return err
}
groupCreatedTunnelIDs := make(map[int64]struct{})
for rows.Next() {
var userTunnelID int64
var createdByGroup int
if err := rows.Scan(&userTunnelID, &createdByGroup); err != nil {
rows.Close()
return err
}
if createdByGroup == 1 && userTunnelID > 0 {
groupCreatedTunnelIDs[userTunnelID] = struct{}{}
}
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
if _, err := tx.Exec(`DELETE FROM group_permission_grant WHERE user_group_id = ? AND tunnel_group_id = ?`, userGroupID, tunnelGroupID); err != nil {
return err
}
for userTunnelID := range groupCreatedTunnelIDs {
var remaining int
if err := tx.QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&remaining); err != nil {
return err
}
if remaining == 0 {
if _, err := tx.Exec(`DELETE FROM user_tunnel WHERE id = ?`, userTunnelID); err != nil {
return err
}
}
}
return nil
}
func queryPairs(db *store.DB, q string, args ...interface{}) ([][2]int64, error) {
rows, err := db.Query(q, args...)
if err != nil {
return nil, err
@@ -1944,7 +2116,7 @@ type tunnelCreateState struct {
NodeIDList []int64
}
func (h *Handler) prepareTunnelCreateState(tx *sql.Tx, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {
func (h *Handler) prepareTunnelCreateState(tx *store.Tx, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {
state := &tunnelCreateState{
Type: tunnelType,
InNodes: make([]tunnelRuntimeNode, 0),
@@ -2061,7 +2233,7 @@ func (h *Handler) prepareTunnelCreateState(tx *sql.Tx, req map[string]interface{
}
return nil, err
}
if node.Status != 1 {
if node.IsRemote != 1 && node.Status != 1 {
return nil, errors.New("部分节点不在线")
}
state.Nodes[nodeID] = node
@@ -2403,7 +2575,7 @@ func (h *Handler) cleanupFederationRuntime(tunnelID int64) {
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)
}
func replaceFederationTunnelBindingsTx(tx *sql.Tx, tunnelID int64, bindings []sqlite.FederationTunnelBinding) error {
func replaceFederationTunnelBindingsTx(tx *store.Tx, tunnelID int64, bindings []sqlite.FederationTunnelBinding) error {
if tx == nil {
return errors.New("database unavailable")
}
@@ -2441,9 +2613,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
}
for _, inNode := range state.InNodes {
if node := state.Nodes[inNode.NodeID]; node != nil && node.IsRemote == 1 {
continue
}
node := state.Nodes[inNode.NodeID]
targets := state.OutNodes
if len(state.ChainHops) > 0 {
targets = state.ChainHops[0]
@@ -2453,6 +2623,9 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
return createdChains, createdServices, err
}
if _, err := h.sendNodeCommand(inNode.NodeID, "AddChains", chainData, true, false); err != nil {
if node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {
continue
}
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
}
createdChains = append(createdChains, inNode.NodeID)
@@ -2564,14 +2737,19 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu
if port <= 0 {
return nil, errors.New("节点端口不能为空")
}
protocol := defaultString(target.Protocol, "tls")
connector := map[string]interface{}{
"type": "relay",
}
if isTLSTunnelProtocol(protocol) {
connector["metadata"] = map[string]interface{}{"nodelay": true}
}
nodeItems = append(nodeItems, map[string]interface{}{
"name": fmt.Sprintf("node_%d", idx+1),
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, port)),
"connector": map[string]interface{}{
"type": "relay",
},
"name": fmt.Sprintf("node_%d", idx+1),
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, port)),
"connector": connector,
"dialer": map[string]interface{}{
"type": defaultString(target.Protocol, "tls"),
"type": protocol,
},
})
}
@@ -2600,14 +2778,19 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
if node == nil {
return nil
}
protocol := defaultString(chainNode.Protocol, "tls")
handlerCfg := map[string]interface{}{
"type": "relay",
}
if isTLSTunnelProtocol(protocol) {
handlerCfg["metadata"] = map[string]interface{}{"nodelay": true}
}
service := map[string]interface{}{
"name": fmt.Sprintf("%d_tls", tunnelID),
"addr": fmt.Sprintf("%s:%d", node.TCPListenAddr, chainNode.Port),
"handler": map[string]interface{}{
"type": "relay",
},
"name": fmt.Sprintf("%d_tls", tunnelID),
"addr": fmt.Sprintf("%s:%d", node.TCPListenAddr, chainNode.Port),
"handler": handlerCfg,
"listener": map[string]interface{}{
"type": defaultString(chainNode.Protocol, "tls"),
"type": protocol,
},
}
if chainNode.ChainType == 2 {
@@ -2653,6 +2836,10 @@ func nodeDisplayName(node *nodeRecord) string {
return fmt.Sprintf("node_%d", node.ID)
}
func isTLSTunnelProtocol(protocol string) bool {
return strings.EqualFold(strings.TrimSpace(defaultString(protocol, "tls")), "tls")
}
func nodeSupportsV4(node *nodeRecord) bool {
if node == nil {
return false
@@ -2713,7 +2900,7 @@ func pickNodeAddressV6(node *nodeRecord) string {
return strings.TrimSpace(node.ServerIP)
}
func isRemoteNodeTx(tx *sql.Tx, nodeID int64) (bool, error) {
func isRemoteNodeTx(tx *store.Tx, nodeID int64) (bool, error) {
if tx == nil {
return false, errors.New("database unavailable")
}
@@ -2730,7 +2917,7 @@ func isRemoteNodeTx(tx *sql.Tx, nodeID int64) (bool, error) {
return isRemote == 1, nil
}
func pickNodePortTx(tx *sql.Tx, nodeID int64, allocated map[int64]int, excludeTunnelID int64) (int, error) {
func pickNodePortTx(tx *store.Tx, nodeID int64, allocated map[int64]int, excludeTunnelID int64) (int, error) {
if tx == nil {
return 0, errors.New("database unavailable")
}
@@ -2841,7 +3028,7 @@ func parsePortRangeSpec(input string) []int {
return out
}
func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{}) error {
func replaceTunnelChainsTx(tx *store.Tx, tunnelID int64, req map[string]interface{}) error {
allocated := map[int64]int{}
inNodes := asMapSlice(req["inNodeId"])
for _, n := range inNodes {
@@ -2849,8 +3036,8 @@ func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{
if nodeID <= 0 {
continue
}
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, NULL, NULL, 0, ?)`,
tunnelID, nodeID, defaultString(asString(n["protocol"]), "tls"))
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, '1', ?, NULL, ?, 0, ?)`,
tunnelID, nodeID, defaultString(asString(n["strategy"]), "round"), defaultString(asString(n["protocol"]), "tls"))
if err != nil {
return err
}
@@ -2868,7 +3055,7 @@ func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{
return pickErr
}
}
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 3, ?, ?, ?, 0, ?)`,
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, '3', ?, ?, ?, 0, ?)`,
tunnelID, nodeID, port, defaultString(asString(n["strategy"]), "round"), defaultString(asString(n["protocol"]), "tls"))
if err != nil {
return err
@@ -2889,7 +3076,7 @@ func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{
return pickErr
}
}
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 2, ?, ?, ?, ?, ?)`,
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, '2', ?, ?, ?, ?, ?)`,
tunnelID, nodeID, port, defaultString(asString(n["strategy"]), "round"), i+1, defaultString(asString(n["protocol"]), "tls"))
if err != nil {
return err
@@ -2975,7 +3162,7 @@ func (h *Handler) batchForwardStatus(ids []int64, status int) (int, int) {
}
func (h *Handler) tunnelEntryNodeIDs(tunnelID int64) ([]int64, error) {
rows, err := h.repo.DB().Query(`SELECT node_id FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 1 ORDER BY inx ASC, id ASC`, tunnelID)
rows, err := h.repo.DB().Query(`SELECT node_id FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = '1' ORDER BY inx ASC, id ASC`, tunnelID)
if err != nil {
return nil, err
}
@@ -3462,7 +3649,7 @@ func randomToken(n int) string {
return hex.EncodeToString(buf)
}
func nextIndex(db *sql.DB, table string) int {
func nextIndex(db *store.DB, table string) int {
if db == nil {
return 0
}
+291
View File
@@ -0,0 +1,291 @@
package handler
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"go-backend/internal/http/response"
)
const (
githubRepo = "Sagit-chu/flvx"
githubProxy = "https://gcode.hostcentral.cc"
githubAPIBase = "https://api.github.com"
githubHTMLBase = "https://github.com"
upgradeTimeout = 5 * time.Minute
batchWorkers = 5
)
func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req struct {
ID int64 `json:"id"`
Version string `json:"version"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("节点ID无效"))
return
}
version := strings.TrimSpace(req.Version)
if version == "" {
var err error
version, err = resolveLatestRelease()
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
return
}
}
downloadURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
githubHTMLBase, githubRepo, version,
)
checksumURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
githubHTMLBase, githubRepo, version,
)
result, err := h.wsServer.SendCommand(req.ID, "UpgradeAgent", map[string]interface{}{
"downloadUrl": downloadURL,
"checksumUrl": checksumURL,
}, upgradeTimeout)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("升级失败: %v", err)))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{
"version": version,
"message": result.Message,
}))
}
func resolveLatestRelease() (string, error) {
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: 10 * time.Second,
}
resp, err := client.Get(githubProxy + "/" + githubHTMLBase + "/" + githubRepo + "/releases/latest")
if err != nil {
return "", fmt.Errorf("请求GitHub失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently {
return resolveLatestReleaseAPI()
}
location := resp.Header.Get("Location")
if location == "" {
return resolveLatestReleaseAPI()
}
parts := strings.Split(location, "/")
tag := parts[len(parts)-1]
if tag == "" || tag == "latest" {
return resolveLatestReleaseAPI()
}
return tag, nil
}
func resolveLatestReleaseAPI() (string, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases/latest")
if err != nil {
return "", fmt.Errorf("请求GitHub API失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
}
var release struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", fmt.Errorf("解析GitHub API响应失败: %v", err)
}
if strings.TrimSpace(release.TagName) == "" {
return "", fmt.Errorf("无法从GitHub获取最新版本号")
}
return release.TagName, nil
}
func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req struct {
IDs []int64 `json:"ids"`
Version string `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if len(req.IDs) == 0 {
response.WriteJSON(w, response.ErrDefault("ids不能为空"))
return
}
version := strings.TrimSpace(req.Version)
if version == "" {
var err error
version, err = resolveLatestRelease()
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
return
}
}
downloadURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
githubHTMLBase, githubRepo, version,
)
checksumURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
githubHTMLBase, githubRepo, version,
)
type upgradeResult struct {
ID int64 `json:"id"`
Success bool `json:"success"`
Message string `json:"message"`
}
results := make([]upgradeResult, len(req.IDs))
sem := make(chan struct{}, batchWorkers)
var wg sync.WaitGroup
for i, id := range req.IDs {
wg.Add(1)
go func(index int, nodeID int64) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
result, err := h.wsServer.SendCommand(nodeID, "UpgradeAgent", map[string]interface{}{
"downloadUrl": downloadURL,
"checksumUrl": checksumURL,
}, upgradeTimeout)
if err != nil {
results[index] = upgradeResult{ID: nodeID, Success: false, Message: err.Error()}
return
}
results[index] = upgradeResult{ID: nodeID, Success: true, Message: result.Message}
}(i, id)
}
wg.Wait()
response.WriteJSON(w, response.OK(map[string]interface{}{
"version": version,
"results": results,
}))
}
func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases?per_page=20")
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: GitHub API返回 %d: %s", resp.StatusCode, string(body))))
return
}
var releases []struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt string `json:"published_at"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("解析版本列表失败: %v", err)))
return
}
type releaseItem struct {
Version string `json:"version"`
Name string `json:"name"`
PublishedAt string `json:"publishedAt"`
Prerelease bool `json:"prerelease"`
}
items := make([]releaseItem, 0, len(releases))
for _, r := range releases {
if r.Draft {
continue
}
items = append(items, releaseItem{
Version: r.TagName,
Name: r.Name,
PublishedAt: r.PublishedAt,
Prerelease: r.Prerelease,
})
}
response.WriteJSON(w, response.OK(items))
}
func (h *Handler) nodeRollback(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req struct {
ID int64 `json:"id"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("节点ID无效"))
return
}
result, err := h.wsServer.SendCommand(req.ID, "RollbackAgent", map[string]interface{}{}, 30*time.Second)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("回退失败: %v", err)))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{
"message": result.Message,
}))
}
@@ -93,6 +93,8 @@ func shouldSkip(path string) bool {
return true
case path == "/api/v1/federation/runtime/diagnose":
return true
case path == "/api/v1/federation/runtime/command":
return true
default:
return false
}
@@ -115,6 +117,14 @@ func requiresAdmin(path string) bool {
return true
}
if strings.HasPrefix(path, "/api/v1/backup/") {
return true
}
if strings.HasPrefix(path, "/api/v1/api/v1/backup/") {
return true
}
if strings.HasPrefix(path, "/api/v1/tunnel/") {
if strings.HasPrefix(path, "/api/v1/tunnel/user/tunnel") {
return false
+466
View File
@@ -0,0 +1,466 @@
// Package store provides a thin dialect-aware wrapper around database/sql,
// enabling transparent use of both SQLite and PostgreSQL.
package store
import (
"database/sql"
"strconv"
"strings"
)
// Dialect identifies the underlying database engine.
type Dialect int
const (
DialectSQLite Dialect = iota
DialectPostgres
)
// String returns a human-readable dialect name.
func (d Dialect) String() string {
switch d {
case DialectSQLite:
return "sqlite"
case DialectPostgres:
return "postgres"
default:
return "unknown"
}
}
// DB wraps *sql.DB with dialect awareness.
type DB struct {
raw *sql.DB
dialect Dialect
}
// Wrap creates a new dialect-aware DB from an existing *sql.DB.
func Wrap(raw *sql.DB, dialect Dialect) *DB {
return &DB{raw: raw, dialect: dialect}
}
// Dialect returns the database dialect.
func (db *DB) Dialect() Dialect {
if db == nil {
return DialectSQLite
}
return db.dialect
}
// RawDB returns the underlying *sql.DB.
func (db *DB) RawDB() *sql.DB {
if db == nil {
return nil
}
return db.raw
}
// Close closes the underlying connection.
func (db *DB) Close() error {
if db == nil || db.raw == nil {
return nil
}
return db.raw.Close()
}
// Ping verifies the connection is alive.
func (db *DB) Ping() error {
return db.raw.Ping()
}
// Exec executes a query with transparent placeholder and syntax rewriting.
func (db *DB) Exec(query string, args ...any) (sql.Result, error) {
return db.raw.Exec(db.rewrite(query), args...)
}
// Query executes a query that returns rows, with transparent rewriting.
func (db *DB) Query(query string, args ...any) (*sql.Rows, error) {
return db.raw.Query(db.rewrite(query), args...)
}
// QueryRow executes a query that returns at most one row, with transparent rewriting.
func (db *DB) QueryRow(query string, args ...any) *sql.Row {
return db.raw.QueryRow(db.rewrite(query), args...)
}
// Begin starts a transaction, returning a dialect-aware Tx.
func (db *DB) Begin() (*Tx, error) {
tx, err := db.raw.Begin()
if err != nil {
return nil, err
}
return &Tx{raw: tx, dialect: db.dialect}, nil
}
// ExecReturningID executes an INSERT and returns the auto-generated id.
// - SQLite: uses LastInsertId()
// - PostgreSQL: appends RETURNING id and uses QueryRow().Scan()
func (db *DB) ExecReturningID(query string, args ...any) (int64, error) {
q := db.rewrite(query)
if db.dialect == DialectPostgres {
q = ensureReturningID(q)
var id int64
if err := db.raw.QueryRow(q, args...).Scan(&id); err != nil {
return 0, err
}
return id, nil
}
res, err := db.raw.Exec(q, args...)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// Tx wraps *sql.Tx with dialect awareness.
type Tx struct {
raw *sql.Tx
dialect Dialect
}
// Exec executes a query inside the transaction with transparent rewriting.
func (tx *Tx) Exec(query string, args ...any) (sql.Result, error) {
return tx.raw.Exec(rewriteQuery(tx.dialect, query), args...)
}
// Query executes a query that returns rows inside the transaction.
func (tx *Tx) Query(query string, args ...any) (*sql.Rows, error) {
return tx.raw.Query(rewriteQuery(tx.dialect, query), args...)
}
// QueryRow executes a query that returns at most one row inside the transaction.
func (tx *Tx) QueryRow(query string, args ...any) *sql.Row {
return tx.raw.QueryRow(rewriteQuery(tx.dialect, query), args...)
}
// Commit commits the transaction.
func (tx *Tx) Commit() error { return tx.raw.Commit() }
// Rollback aborts the transaction.
func (tx *Tx) Rollback() error { return tx.raw.Rollback() }
// ExecReturningID executes an INSERT inside the transaction and returns the id.
func (tx *Tx) ExecReturningID(query string, args ...any) (int64, error) {
q := rewriteQuery(tx.dialect, query)
if tx.dialect == DialectPostgres {
q = ensureReturningID(q)
var id int64
if err := tx.raw.QueryRow(q, args...).Scan(&id); err != nil {
return 0, err
}
return id, nil
}
res, err := tx.raw.Exec(q, args...)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (db *DB) rewrite(query string) string {
return rewriteQuery(db.dialect, query)
}
func rewriteQuery(dialect Dialect, query string) string {
if dialect != DialectPostgres {
return query
}
query = rewriteUserIdentifier(query)
query = rewriteInsertOrIgnore(query)
query = rewritePlaceholders(query)
return query
}
func rewriteUserIdentifier(query string) string {
var buf strings.Builder
buf.Grow(len(query) + 16)
i := 0
for i < len(query) {
if end, ok := skipSQLProtectedSegment(query, i); ok {
buf.WriteString(query[i:end])
i = end
continue
}
ch := query[i]
if isIdentifierChar(ch) {
j := i + 1
for j < len(query) && isIdentifierChar(query[j]) {
j++
}
tok := query[i:j]
if strings.EqualFold(tok, "user") {
buf.WriteString(`"user"`)
} else {
buf.WriteString(tok)
}
i = j
continue
}
buf.WriteByte(ch)
i++
}
return buf.String()
}
func isIdentifierChar(ch byte) bool {
if ch >= 'a' && ch <= 'z' {
return true
}
if ch >= 'A' && ch <= 'Z' {
return true
}
if ch >= '0' && ch <= '9' {
return true
}
return ch == '_'
}
func rewriteInsertOrIgnore(query string) string {
start, end, ok := findKeywordSequenceOutside(query, []string{"INSERT", "OR", "IGNORE", "INTO"}, 0)
if !ok {
return query
}
rewritten := query[:start] + "INSERT INTO" + query[end:]
rewritten = strings.TrimRight(rewritten, "; \t\n")
insertIntoEnd := start + len("INSERT INTO")
if _, _, hasOnConflict := findKeywordSequenceOutside(rewritten, []string{"ON", "CONFLICT"}, insertIntoEnd); hasOnConflict {
return rewritten
}
if retStart, _, hasReturning := findKeywordSequenceOutside(rewritten, []string{"RETURNING"}, insertIntoEnd); hasReturning {
prefix := strings.TrimRight(rewritten[:retStart], " \t\n")
suffix := strings.TrimLeft(rewritten[retStart:], " \t\n")
return prefix + " ON CONFLICT DO NOTHING " + suffix
}
return rewritten + " ON CONFLICT DO NOTHING"
}
func rewritePlaceholders(query string) string {
var buf strings.Builder
buf.Grow(len(query) + 16)
n := 1
for i := 0; i < len(query); i++ {
if end, ok := skipSQLProtectedSegment(query, i); ok {
buf.WriteString(query[i:end])
i = end - 1
continue
}
ch := query[i]
if ch == '?' {
buf.WriteByte('$')
buf.WriteString(strconv.Itoa(n))
n++
continue
}
buf.WriteByte(ch)
}
return buf.String()
}
func ensureReturningID(query string) string {
trimmed := strings.TrimRight(query, "; \t\n")
if _, _, ok := findKeywordSequenceOutside(trimmed, []string{"RETURNING"}, 0); ok {
return trimmed
}
return trimmed + " RETURNING id"
}
func findKeywordSequenceOutside(query string, keywords []string, from int) (int, int, bool) {
if len(keywords) == 0 {
return 0, 0, false
}
if from < 0 {
from = 0
}
if from >= len(query) {
return 0, 0, false
}
matched := 0
seqStart := -1
for i := from; i < len(query); {
if end, ok := skipSQLProtectedSegment(query, i); ok {
i = end
continue
}
ch := query[i]
if isIdentifierChar(ch) {
j := i + 1
for j < len(query) && isIdentifierChar(query[j]) {
j++
}
tok := query[i:j]
if strings.EqualFold(tok, keywords[matched]) {
if matched == 0 {
seqStart = i
}
matched++
if matched == len(keywords) {
return seqStart, j, true
}
} else if strings.EqualFold(tok, keywords[0]) {
seqStart = i
matched = 1
} else {
matched = 0
seqStart = -1
}
i = j
continue
}
if !isSQLSpace(ch) {
matched = 0
seqStart = -1
}
i++
}
return 0, 0, false
}
func skipSQLProtectedSegment(query string, i int) (int, bool) {
if i < 0 || i >= len(query) {
return 0, false
}
switch query[i] {
case '\'':
return skipSingleQuotedLiteral(query, i), true
case '"':
return skipDoubleQuotedIdentifier(query, i), true
case '-':
if i+1 < len(query) && query[i+1] == '-' {
return skipLineComment(query, i), true
}
case '/':
if i+1 < len(query) && query[i+1] == '*' {
return skipBlockComment(query, i), true
}
case '$':
if end, ok := skipDollarQuotedLiteral(query, i); ok {
return end, true
}
}
return 0, false
}
func skipSingleQuotedLiteral(query string, i int) int {
for j := i + 1; j < len(query); j++ {
if query[j] != '\'' {
continue
}
if j+1 < len(query) && query[j+1] == '\'' {
j++
continue
}
return j + 1
}
return len(query)
}
func skipDoubleQuotedIdentifier(query string, i int) int {
for j := i + 1; j < len(query); j++ {
if query[j] != '"' {
continue
}
if j+1 < len(query) && query[j+1] == '"' {
j++
continue
}
return j + 1
}
return len(query)
}
func skipLineComment(query string, i int) int {
for j := i + 2; j < len(query); j++ {
if query[j] == '\n' {
return j
}
}
return len(query)
}
func skipBlockComment(query string, i int) int {
depth := 1
for j := i + 2; j < len(query)-1; j++ {
if query[j] == '/' && query[j+1] == '*' {
depth++
j++
continue
}
if query[j] == '*' && query[j+1] == '/' {
depth--
j++
if depth == 0 {
return j + 1
}
}
}
return len(query)
}
func skipDollarQuotedLiteral(query string, i int) (int, bool) {
if i < 0 || i >= len(query) || query[i] != '$' {
return 0, false
}
if i+1 >= len(query) {
return 0, false
}
var endTag int
if query[i+1] == '$' {
endTag = i + 1
} else {
if !isDollarTagStart(query[i+1]) {
return 0, false
}
j := i + 2
for j < len(query) && isDollarTagChar(query[j]) {
j++
}
if j >= len(query) || query[j] != '$' {
return 0, false
}
endTag = j
}
tag := query[i : endTag+1]
if closeIdx := strings.Index(query[endTag+1:], tag); closeIdx >= 0 {
return endTag + 1 + closeIdx + len(tag), true
}
return len(query), true
}
func isDollarTagStart(ch byte) bool {
return ch == '_' || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
}
func isDollarTagChar(ch byte) bool {
if isDollarTagStart(ch) {
return true
}
return ch >= '0' && ch <= '9'
}
func isSQLSpace(ch byte) bool {
switch ch {
case ' ', '\t', '\n', '\r', '\f':
return true
default:
return false
}
}
+116
View File
@@ -0,0 +1,116 @@
package store
import "testing"
func TestRewritePlaceholdersSkipsProtectedSegments(t *testing.T) {
q := `SELECT ?, '?', "id?", $$body ? $$, $tag$X?$tag$, col -- comment ?
FROM t /* block ? */ WHERE id = ?`
got := rewritePlaceholders(q)
want := `SELECT $1, '?', "id?", $$body ? $$, $tag$X?$tag$, col -- comment ?
FROM t /* block ? */ WHERE id = $2`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestRewriteInsertOrIgnoreBasic(t *testing.T) {
q := `INSERT OR IGNORE INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?)`
got := rewriteInsertOrIgnore(q)
want := `INSERT INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestRewriteInsertOrIgnoreBeforeReturning(t *testing.T) {
q := `INSERT OR IGNORE INTO x(a) VALUES(?) RETURNING id`
got := rewriteInsertOrIgnore(q)
want := `INSERT INTO x(a) VALUES(?) ON CONFLICT DO NOTHING RETURNING id`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestRewriteInsertOrIgnoreNotDuplicatingOnConflict(t *testing.T) {
q := `INSERT OR IGNORE INTO x(a) VALUES(?) ON CONFLICT(a) DO UPDATE SET a=excluded.a`
got := rewriteInsertOrIgnore(q)
want := `INSERT INTO x(a) VALUES(?) ON CONFLICT(a) DO UPDATE SET a=excluded.a`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestEnsureReturningID(t *testing.T) {
if got := ensureReturningID(`INSERT INTO x(a) VALUES($1)`); got != `INSERT INTO x(a) VALUES($1) RETURNING id` {
t.Fatalf("missing RETURNING append: %s", got)
}
if got := ensureReturningID(`INSERT INTO x(a) VALUES($1) RETURNING other_id`); got != `INSERT INTO x(a) VALUES($1) RETURNING other_id` {
t.Fatalf("RETURNING should not be duplicated: %s", got)
}
}
func TestRewriteUserIdentifierSafety(t *testing.T) {
q := `SELECT user, user_id, 'user', "user", note FROM user -- user
WHERE owner='user'`
got := rewriteUserIdentifier(q)
want := `SELECT "user", user_id, 'user', "user", note FROM "user" -- user
WHERE owner='user'`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestRewriteQueryPostgresPipeline(t *testing.T) {
q := `INSERT OR IGNORE INTO user(name, note) VALUES(?, '?')`
got := rewriteQuery(DialectPostgres, q)
want := `INSERT INTO "user"(name, note) VALUES($1, '?') ON CONFLICT DO NOTHING`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestRewriteInsertOrIgnoreSkipsStringLiteral(t *testing.T) {
q := `SELECT 'INSERT OR IGNORE INTO t(a) VALUES(?)' AS q`
got := rewriteInsertOrIgnore(q)
if got != q {
t.Fatalf("string literal should stay unchanged\nwant: %s\ngot: %s", q, got)
}
}
func TestRewriteInsertOrIgnoreSkipsCommentedKeyword(t *testing.T) {
q := `-- INSERT OR IGNORE INTO ignored(a) VALUES(?)
INSERT OR IGNORE INTO real_t(a) VALUES(?)`
got := rewriteInsertOrIgnore(q)
want := `-- INSERT OR IGNORE INTO ignored(a) VALUES(?)
INSERT INTO real_t(a) VALUES(?) ON CONFLICT DO NOTHING`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestRewritePlaceholdersSkipsNestedBlockComment(t *testing.T) {
q := `SELECT ? /* outer ? /* inner ? */ still_outer ? */ FROM t WHERE id = ?`
got := rewritePlaceholders(q)
want := `SELECT $1 /* outer ? /* inner ? */ still_outer ? */ FROM t WHERE id = $2`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestRewritePlaceholdersSkipsUnterminatedBlockComment(t *testing.T) {
q := `SELECT ? /* unterminated ? comment`
got := rewritePlaceholders(q)
want := `SELECT $1 /* unterminated ? comment`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestRewriteUserIdentifierSkipsDollarQuotedAndComment(t *testing.T) {
q := `SELECT user, $$user ?$$ AS body, col FROM user /* user */ -- user`
got := rewriteUserIdentifier(q)
want := `SELECT "user", $$user ?$$ AS body, col FROM "user" /* user */ -- user`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
@@ -0,0 +1,9 @@
package postgres
import _ "embed"
//go:embed sql/schema.sql
var EmbeddedSchema string
//go:embed sql/data.sql
var EmbeddedSeedData string
@@ -0,0 +1,18 @@
INSERT INTO "user" (id, "user", pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES (1, 'admin_user', '3c85cdebade1c51cf64ca9f3c09d182d', 0, 2727251700000, 99999, 0, 0, 1, 99999, 1748914865000, 1754011744252, 1)
ON CONFLICT DO NOTHING;
INSERT INTO vite_config (id, name, value, time)
VALUES (1, 'app_name', 'flux', 1755147963000)
ON CONFLICT DO NOTHING;
DO $$
BEGIN
IF to_regclass('public.user_id_seq') IS NOT NULL THEN
PERFORM setval('user_id_seq', (SELECT COALESCE(MAX(id), 0) FROM "user"));
END IF;
IF to_regclass('public.vite_config_id_seq') IS NOT NULL THEN
PERFORM setval('vite_config_id_seq', (SELECT COALESCE(MAX(id), 0) FROM vite_config));
END IF;
END
$$;
@@ -0,0 +1,241 @@
CREATE TABLE IF NOT EXISTS forward (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
user_name VARCHAR(100) NOT NULL,
name VARCHAR(100) NOT NULL,
tunnel_id INTEGER NOT NULL,
remote_addr TEXT NOT NULL,
strategy VARCHAR(100) NOT NULL DEFAULT 'fifo',
in_flow BIGINT NOT NULL DEFAULT 0,
out_flow BIGINT NOT NULL DEFAULT 0,
created_time BIGINT NOT NULL,
updated_time BIGINT NOT NULL,
status INTEGER NOT NULL,
inx INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS forward_port (
id SERIAL PRIMARY KEY,
forward_id INTEGER NOT NULL,
node_id INTEGER NOT NULL,
port INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS node (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
secret VARCHAR(100) NOT NULL,
server_ip VARCHAR(100) NOT NULL,
server_ip_v4 VARCHAR(100),
server_ip_v6 VARCHAR(100),
port TEXT NOT NULL,
interface_name VARCHAR(200),
version VARCHAR(100),
http INTEGER NOT NULL DEFAULT 0,
tls INTEGER NOT NULL DEFAULT 0,
socks INTEGER NOT NULL DEFAULT 0,
created_time BIGINT NOT NULL,
updated_time BIGINT,
status INTEGER NOT NULL,
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
inx INTEGER NOT NULL DEFAULT 0,
is_remote INTEGER DEFAULT 0,
remote_url TEXT,
remote_token TEXT,
remote_config TEXT
);
CREATE TABLE IF NOT EXISTS speed_limit (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
speed INTEGER NOT NULL,
tunnel_id INTEGER NOT NULL,
tunnel_name VARCHAR(100) NOT NULL,
created_time BIGINT NOT NULL,
updated_time BIGINT,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS statistics_flow (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
flow BIGINT NOT NULL,
total_flow BIGINT NOT NULL,
time VARCHAR(100) NOT NULL,
created_time BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS tunnel (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
traffic_ratio DOUBLE PRECISION NOT NULL DEFAULT 1.0,
type INTEGER NOT NULL,
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
flow BIGINT NOT NULL,
created_time BIGINT NOT NULL,
updated_time BIGINT NOT NULL,
status INTEGER NOT NULL,
in_ip TEXT,
inx INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS chain_tunnel (
id SERIAL PRIMARY KEY,
tunnel_id INTEGER NOT NULL,
chain_type VARCHAR(10) NOT NULL,
node_id INTEGER NOT NULL,
port INTEGER,
strategy VARCHAR(10),
inx INTEGER,
protocol VARCHAR(10)
);
CREATE TABLE IF NOT EXISTS "user" (
id SERIAL PRIMARY KEY,
"user" VARCHAR(100) NOT NULL,
pwd VARCHAR(100) NOT NULL,
role_id INTEGER NOT NULL,
exp_time BIGINT NOT NULL,
flow BIGINT NOT NULL,
in_flow BIGINT NOT NULL DEFAULT 0,
out_flow BIGINT NOT NULL DEFAULT 0,
flow_reset_time BIGINT NOT NULL,
num INTEGER NOT NULL,
created_time BIGINT NOT NULL,
updated_time BIGINT,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_tunnel (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
tunnel_id INTEGER NOT NULL,
speed_id INTEGER,
num INTEGER NOT NULL,
flow BIGINT NOT NULL,
in_flow BIGINT NOT NULL DEFAULT 0,
out_flow BIGINT NOT NULL DEFAULT 0,
flow_reset_time BIGINT NOT NULL,
exp_time BIGINT NOT NULL,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tunnel_group (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_time BIGINT NOT NULL,
updated_time BIGINT NOT NULL,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_group (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_time BIGINT NOT NULL,
updated_time BIGINT NOT NULL,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tunnel_group_tunnel (
id SERIAL PRIMARY KEY,
tunnel_group_id INTEGER NOT NULL,
tunnel_id INTEGER NOT NULL,
created_time BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_group_user (
id SERIAL PRIMARY KEY,
user_group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_time BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS group_permission (
id SERIAL PRIMARY KEY,
user_group_id INTEGER NOT NULL,
tunnel_group_id INTEGER NOT NULL,
created_time BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS group_permission_grant (
id SERIAL PRIMARY KEY,
user_group_id INTEGER NOT NULL,
tunnel_group_id INTEGER NOT NULL,
user_tunnel_id INTEGER NOT NULL,
created_by_group INTEGER NOT NULL DEFAULT 0,
created_time BIGINT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_name ON tunnel_group(name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_name ON user_group(name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_tunnel_unique ON tunnel_group_tunnel(tunnel_group_id, tunnel_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_user_unique ON user_group_user(user_group_id, user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_unique ON group_permission(user_group_id, tunnel_group_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_grant_unique ON group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_tunnel_unique ON user_tunnel(user_id, tunnel_id);
CREATE TABLE IF NOT EXISTS vite_config (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL UNIQUE,
value VARCHAR(200) NOT NULL,
time BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS peer_share (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
node_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
max_bandwidth INTEGER DEFAULT 0,
expiry_time BIGINT DEFAULT 0,
port_range_start INTEGER DEFAULT 0,
port_range_end INTEGER DEFAULT 0,
current_flow BIGINT DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_time BIGINT NOT NULL,
updated_time BIGINT NOT NULL,
allowed_domains TEXT DEFAULT '',
allowed_ips TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS peer_share_runtime (
id SERIAL PRIMARY KEY,
share_id INTEGER NOT NULL,
node_id INTEGER NOT NULL,
reservation_id TEXT NOT NULL UNIQUE,
resource_key TEXT NOT NULL UNIQUE,
binding_id TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT '',
chain_name TEXT NOT NULL DEFAULT '',
service_name TEXT NOT NULL DEFAULT '',
protocol TEXT NOT NULL DEFAULT 'tls',
strategy TEXT NOT NULL DEFAULT 'round',
port INTEGER NOT NULL DEFAULT 0,
target TEXT NOT NULL DEFAULT '',
applied INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
created_time BIGINT NOT NULL,
updated_time BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_share_node_status ON peer_share_runtime(share_id, node_id, status);
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_binding_id ON peer_share_runtime(binding_id);
CREATE TABLE IF NOT EXISTS federation_tunnel_binding (
id SERIAL PRIMARY KEY,
tunnel_id INTEGER NOT NULL,
node_id INTEGER NOT NULL,
chain_type INTEGER NOT NULL,
hop_inx INTEGER NOT NULL DEFAULT 0,
remote_url TEXT NOT NULL,
resource_key TEXT NOT NULL UNIQUE,
remote_binding_id TEXT NOT NULL,
allocated_port INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 1,
created_time BIGINT NOT NULL,
updated_time BIGINT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_federation_tunnel_binding_unique ON federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx);
CREATE INDEX IF NOT EXISTS idx_federation_tunnel_binding_tunnel ON federation_tunnel_binding(tunnel_id, status);
File diff suppressed because it is too large Load Diff
+64 -1
View File
@@ -54,6 +54,12 @@ type pendingRequest struct {
ch chan CommandResult
}
const (
wsPingPeriod = 15 * time.Second
wsPongWait = 45 * time.Second
wsWriteWait = 5 * time.Second
)
type CommandResult struct {
Type string `json:"type"`
Success bool `json:"success"`
@@ -120,12 +126,19 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
return
}
cw := &connWrap{conn: conn}
_ = conn.SetReadDeadline(time.Now().Add(wsPongWait))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
})
done := make(chan struct{})
go startKeepalive(cw, done)
s.mu.Lock()
s.admins[cw] = struct{}{}
s.mu.Unlock()
defer func() {
close(done)
s.mu.Lock()
delete(s.admins, cw)
s.mu.Unlock()
@@ -145,6 +158,12 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
return
}
cw := &connWrap{conn: conn}
_ = conn.SetReadDeadline(time.Now().Add(wsPongWait))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
})
done := make(chan struct{})
go startKeepalive(cw, done)
version := r.URL.Query().Get("version")
httpVal := parseIntDefault(r.URL.Query().Get("http"), 0)
@@ -165,6 +184,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
s.broadcastStatus(nodeID, 1)
defer func() {
close(done)
needOfflineBroadcast := false
s.mu.Lock()
current, ok := s.nodes[nodeID]
@@ -190,7 +210,15 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
msg := decryptIfNeeded(payload, secret)
s.tryResolvePending(nodeID, msg)
s.broadcastInfo(nodeID, msg)
var parsed struct {
Type string `json:"type"`
}
if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type == "UpgradeProgress" {
s.broadcastTyped(nodeID, "upgrade_progress", msg)
} else {
s.broadcastInfo(nodeID, msg)
}
}
}
@@ -264,7 +292,9 @@ func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, tim
}
ns.conn.mu.Lock()
_ = ns.conn.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
err = ns.conn.conn.WriteMessage(websocket.TextMessage, messageData)
_ = ns.conn.conn.SetWriteDeadline(time.Time{})
ns.conn.mu.Unlock()
if err != nil {
cleanup()
@@ -385,6 +415,12 @@ func (s *Server) broadcastInfo(nodeID int64, data string) {
s.broadcastToAdmins(string(raw))
}
func (s *Server) broadcastTyped(nodeID int64, msgType string, data string) {
payload := broadcastMessage{ID: nodeID, Type: msgType, Data: data}
raw, _ := json.Marshal(payload)
s.broadcastToAdmins(string(raw))
}
func (s *Server) broadcastToAdmins(message string) {
s.mu.RLock()
admins := make([]*connWrap, 0, len(s.admins))
@@ -395,7 +431,9 @@ func (s *Server) broadcastToAdmins(message string) {
for _, c := range admins {
c.mu.Lock()
_ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
err := c.conn.WriteMessage(websocket.TextMessage, []byte(message))
_ = c.conn.SetWriteDeadline(time.Time{})
c.mu.Unlock()
if err != nil {
log.Printf("websocket broadcast failed: %v", err)
@@ -428,3 +466,28 @@ func parseIntDefault(v string, fallback int) int {
}
return x
}
func startKeepalive(cw *connWrap, done <-chan struct{}) {
if cw == nil || cw.conn == nil {
return
}
ticker := time.NewTicker(wsPingPeriod)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
cw.mu.Lock()
_ = cw.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
err := cw.conn.WriteMessage(websocket.PingMessage, nil)
_ = cw.conn.SetWriteDeadline(time.Time{})
cw.mu.Unlock()
if err != nil {
_ = cw.conn.Close()
return
}
}
}
}
@@ -78,6 +78,8 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token")
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-token")
stopEntry := startMockNodeSession(t, providerServer.URL, "provider-entry-secret")
defer stopEntry()
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-secret")
defer stopMiddle()
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-secret")
@@ -148,6 +150,23 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 2, middleRemoteNodeID, 44000, 44010)
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 3, exitRemoteNodeID, 45000, 45010)
forwardPayload := map[string]interface{}{
"name": "dual-panel-remote-entry-forward",
"tunnelId": secondTunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
}
forwardBody, err := json.Marshal(forwardPayload)
if err != nil {
t.Fatalf("marshal forward payload: %v", err)
}
forwardReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(forwardBody))
forwardReq.Header.Set("Authorization", consumerAdminToken)
forwardReq.Header.Set("Content-Type", "application/json")
forwardRes := httptest.NewRecorder()
consumerRouter.ServeHTTP(forwardRes, forwardReq)
assertCode(t, forwardRes, 0)
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, middleShareID, 1)
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, exitShareID, 1)
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, entryShareID, 0)
@@ -297,6 +316,136 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
}
}
func TestFederationDualPanelRemoteEntryRuntimeContract(t *testing.T) {
providerSecret := "provider-contract-jwt"
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
providerServer := httptest.NewServer(providerRouter)
defer providerServer.Close()
consumerSecret := "consumer-contract-jwt"
consumerRouter, consumerRepo := setupContractRouter(t, consumerSecret)
consumerAdminToken, err := auth.GenerateToken(1, "consumer-admin", 0, consumerSecret)
if err != nil {
t.Fatalf("generate consumer admin token: %v", err)
}
now := time.Now().UnixMilli()
providerEntryNodeID := insertContractNode(t, providerRepo, "provider-entry-rt", "198.51.100.21", "43020-43030", "provider-entry-rt-secret", 1)
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle-rt", "198.51.100.22", "44020-44030", "provider-middle-rt-secret", 1)
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit-rt", "198.51.100.23", "45020-45030", "provider-exit-rt-secret", 1)
insertPeerShare(t, providerRepo, &sqlite.PeerShare{
Name: "entry-share-rt",
NodeID: providerEntryNodeID,
Token: "share-entry-rt-token",
PortRangeStart: 43020,
PortRangeEnd: 43030,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
insertPeerShare(t, providerRepo, &sqlite.PeerShare{
Name: "middle-share-rt",
NodeID: providerMiddleNodeID,
Token: "share-middle-rt-token",
PortRangeStart: 44020,
PortRangeEnd: 44030,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
insertPeerShare(t, providerRepo, &sqlite.PeerShare{
Name: "exit-share-rt",
NodeID: providerExitNodeID,
Token: "share-exit-rt-token",
PortRangeStart: 45020,
PortRangeEnd: 45030,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-entry-rt-token")
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-middle-rt-token")
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-exit-rt-token")
entryRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-entry-rt-token")
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-rt-token")
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-rt-token")
var commandMu sync.Mutex
entryCommands := make([]string, 0, 8)
stopEntry := startMockNodeSessionWithHook(t, providerServer.URL, "provider-entry-rt-secret", func(cmdType string) {
commandMu.Lock()
entryCommands = append(entryCommands, cmdType)
commandMu.Unlock()
})
defer stopEntry()
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-rt-secret")
defer stopMiddle()
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-rt-secret")
defer stopExit()
createTunnel := func(name string) int64 {
payload := map[string]interface{}{
"name": name,
"type": 2,
"flow": 99999,
"status": 1,
"inNodeId": []map[string]interface{}{
{"nodeId": entryRemoteNodeID, "protocol": "tls", "strategy": "round"},
},
"chainNodes": [][]map[string]interface{}{
{{"nodeId": middleRemoteNodeID, "protocol": "tls", "strategy": "round"}},
},
"outNodeId": []map[string]interface{}{
{"nodeId": exitRemoteNodeID, "protocol": "tls", "strategy": "round"},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", consumerAdminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
consumerRouter.ServeHTTP(res, req)
assertCode(t, res, 0)
var tunnelID int64
if err := consumerRepo.DB().QueryRow(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name).Scan(&tunnelID); err != nil {
t.Fatalf("query tunnel id (%s): %v", name, err)
}
if tunnelID <= 0 {
t.Fatalf("invalid tunnel id for %s", name)
}
return tunnelID
}
createTunnel("dual-panel-remote-entry-online")
commandMu.Lock()
seenAddChains := false
seenCommands := append([]string(nil), entryCommands...)
for _, cmdType := range entryCommands {
if strings.EqualFold(strings.TrimSpace(cmdType), "AddChains") {
seenAddChains = true
break
}
}
commandMu.Unlock()
if !seenAddChains {
t.Fatalf("expected entry remote node to receive AddChains, commands=%v", seenCommands)
}
stopEntry()
waitNodeStatus(t, providerRepo, providerEntryNodeID, 0)
createTunnel("dual-panel-remote-entry-offline")
}
func insertContractNode(t *testing.T, repo *sqlite.Repository, name, ip, portRange, secret string, status int) int64 {
t.Helper()
now := time.Now().UnixMilli()
@@ -390,6 +539,10 @@ func assertCount(t *testing.T, repo *sqlite.Repository, query string, arg interf
}
func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func() {
return startMockNodeSessionWithHook(t, baseURL, nodeSecret, nil)
}
func startMockNodeSessionWithHook(t *testing.T, baseURL string, nodeSecret string, onCommand func(cmdType string)) func() {
t.Helper()
u, err := url.Parse(baseURL)
if err != nil {
@@ -449,6 +602,9 @@ func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func(
if strings.TrimSpace(cmd.RequestID) == "" {
continue
}
if onCommand != nil {
onCommand(strings.TrimSpace(cmd.Type))
}
respType := fmt.Sprintf("%sResponse", cmd.Type)
respPayload := map[string]interface{}{
@@ -473,9 +629,27 @@ func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func(
}
}()
var stopOnce sync.Once
return func() {
_ = conn.Close()
wg.Wait()
stopOnce.Do(func() {
_ = conn.Close()
wg.Wait()
})
}
}
func waitNodeStatus(t *testing.T, repo *sqlite.Repository, nodeID int64, expectedStatus int) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
var status int
if err := repo.DB().QueryRow(`SELECT status FROM node WHERE id = ?`, nodeID).Scan(&status); err == nil && status == expectedStatus {
return
}
if time.Now().After(deadline) {
t.Fatalf("node %d status did not reach %d before timeout", nodeID, expectedStatus)
}
time.Sleep(20 * time.Millisecond)
}
}
@@ -0,0 +1,219 @@
package contract_test
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-backend/internal/auth"
)
func TestGroupUserUnbindRevokesInheritedTunnelPermission(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
if _, err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(200, 'group_user_contract', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now); err != nil {
t.Fatalf("insert test user: %v", err)
}
tunnelRes, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES('group-contract-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, now, now)
if err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID, err := tunnelRes.LastInsertId()
if err != nil {
t.Fatalf("read tunnel id: %v", err)
}
ugRes, err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-contract', ?, ?, 1)`, now, now)
if err != nil {
t.Fatalf("insert user_group: %v", err)
}
userGroupID, err := ugRes.LastInsertId()
if err != nil {
t.Fatalf("read user_group id: %v", err)
}
tgRes, err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-contract', ?, ?, 1)`, now, now)
if err != nil {
t.Fatalf("insert tunnel_group: %v", err)
}
tunnelGroupID, err := tgRes.LastInsertId()
if err != nil {
t.Fatalf("read tunnel_group id: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO tunnel_group_tunnel(tunnel_group_id, tunnel_id, created_time) VALUES(?, ?, ?)`, tunnelGroupID, tunnelID, now); err != nil {
t.Fatalf("insert tunnel_group_tunnel: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO group_permission(user_group_id, tunnel_group_id, created_time) VALUES(?, ?, ?)`, userGroupID, tunnelGroupID, now); err != nil {
t.Fatalf("insert group_permission: %v", err)
}
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
bindReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[200]}`))
bindReq.Header.Set("Authorization", adminToken)
bindRes := httptest.NewRecorder()
router.ServeHTTP(bindRes, bindReq)
assertCode(t, bindRes, 0)
var userTunnelID int64
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 200 AND tunnel_id = ?`, tunnelID).Scan(&userTunnelID); err != nil {
t.Fatalf("query user_tunnel after bind: %v", err)
}
var grantCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
t.Fatalf("query group_permission_grant after bind: %v", err)
}
if grantCount == 0 {
t.Fatalf("expected non-zero grants after bind")
}
unbindReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[]}`))
unbindReq.Header.Set("Authorization", adminToken)
unbindRes := httptest.NewRecorder()
router.ServeHTTP(unbindRes, unbindReq)
assertCode(t, unbindRes, 0)
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
t.Fatalf("query group_permission_grant after unbind: %v", err)
}
if grantCount != 0 {
t.Fatalf("expected grants revoked after unbind, got %d", grantCount)
}
var userTunnelCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID).Scan(&userTunnelCount); err != nil {
t.Fatalf("query user_tunnel after unbind: %v", err)
}
if userTunnelCount != 0 {
t.Fatalf("expected user_tunnel revoked after unbind, got %d", userTunnelCount)
}
}
func TestGroupPermissionRemoveRevokesInheritedTunnelPermission(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
if _, err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(201, 'group_user_permission_remove', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now); err != nil {
t.Fatalf("insert test user: %v", err)
}
tunnelRes, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES('group-remove-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, now, now)
if err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID, err := tunnelRes.LastInsertId()
if err != nil {
t.Fatalf("read tunnel id: %v", err)
}
ugRes, err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-remove-contract', ?, ?, 1)`, now, now)
if err != nil {
t.Fatalf("insert user_group: %v", err)
}
userGroupID, err := ugRes.LastInsertId()
if err != nil {
t.Fatalf("read user_group id: %v", err)
}
tgRes, err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-remove-contract', ?, ?, 1)`, now, now)
if err != nil {
t.Fatalf("insert tunnel_group: %v", err)
}
tunnelGroupID, err := tgRes.LastInsertId()
if err != nil {
t.Fatalf("read tunnel_group id: %v", err)
}
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
assignTunnelReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/tunnel/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(tunnelGroupID)+`,"tunnelIds":[`+jsonNumber(tunnelID)+`]}`))
assignTunnelReq.Header.Set("Authorization", adminToken)
assignTunnelRes := httptest.NewRecorder()
router.ServeHTTP(assignTunnelRes, assignTunnelReq)
assertCode(t, assignTunnelRes, 0)
assignUserReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[201]}`))
assignUserReq.Header.Set("Authorization", adminToken)
assignUserRes := httptest.NewRecorder()
router.ServeHTTP(assignUserRes, assignUserReq)
assertCode(t, assignUserRes, 0)
assignPermissionReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/permission/assign", bytes.NewBufferString(`{"userGroupId":`+jsonNumber(userGroupID)+`,"tunnelGroupId":`+jsonNumber(tunnelGroupID)+`}`))
assignPermissionReq.Header.Set("Authorization", adminToken)
assignPermissionRes := httptest.NewRecorder()
router.ServeHTTP(assignPermissionRes, assignPermissionReq)
assertCode(t, assignPermissionRes, 0)
var permissionID int64
if err := repo.DB().QueryRow(`SELECT id FROM group_permission WHERE user_group_id = ? AND tunnel_group_id = ?`, userGroupID, tunnelGroupID).Scan(&permissionID); err != nil {
t.Fatalf("query group_permission id: %v", err)
}
var userTunnelID int64
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 201 AND tunnel_id = ?`, tunnelID).Scan(&userTunnelID); err != nil {
t.Fatalf("query user_tunnel after assign: %v", err)
}
var grantCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
t.Fatalf("query group_permission_grant after assign: %v", err)
}
if grantCount == 0 {
t.Fatalf("expected non-zero grants after permission assign")
}
removeReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/permission/remove", bytes.NewBufferString(`{"id":`+jsonNumber(permissionID)+`}`))
removeReq.Header.Set("Authorization", adminToken)
removeRes := httptest.NewRecorder()
router.ServeHTTP(removeRes, removeReq)
assertCode(t, removeRes, 0)
var permissionCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission WHERE id = ?`, permissionID).Scan(&permissionCount); err != nil {
t.Fatalf("query group_permission after remove: %v", err)
}
if permissionCount != 0 {
t.Fatalf("expected group_permission removed, got %d", permissionCount)
}
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
t.Fatalf("query group_permission_grant after remove: %v", err)
}
if grantCount != 0 {
t.Fatalf("expected grants removed after permission remove, got %d", grantCount)
}
var userTunnelCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID).Scan(&userTunnelCount); err != nil {
t.Fatalf("query user_tunnel after permission remove: %v", err)
}
if userTunnelCount != 0 {
t.Fatalf("expected user_tunnel revoked after permission remove, got %d", userTunnelCount)
}
}
@@ -17,6 +17,7 @@ import (
httpserver "go-backend/internal/http"
"go-backend/internal/http/handler"
"go-backend/internal/http/response"
"go-backend/internal/store"
"go-backend/internal/store/sqlite"
_ "modernc.org/sqlite"
@@ -202,6 +203,234 @@ func TestSpeedLimitTunnelsRouteAlias(t *testing.T) {
})
}
func TestBackupExportImportRestoreContracts(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
if err != nil {
t.Fatalf("generate user token: %v", err)
}
key := "backup_contract_key"
if _, err := repo.DB().Exec(`
INSERT INTO vite_config(name, value, time)
VALUES(?, ?, ?)
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
`, key, "v1", time.Now().UnixMilli()); err != nil {
t.Fatalf("seed config for backup contract: %v", err)
}
t.Run("non-admin is blocked on backup export", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", nil)
req.Header.Set("Authorization", userToken)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCodeMsg(t, resp, 403, "权限不足,仅管理员可操作")
})
t.Run("standard and duplicate export routes both work", func(t *testing.T) {
payloadA := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
if len(payloadA.Configs) == 0 {
t.Fatalf("expected exported configs, got none")
}
if _, ok := payloadA.Configs[key]; !ok {
t.Fatalf("expected %q in exported configs", key)
}
payloadB := exportBackupPayload(t, router, "/api/v1/api/v1/backup/export", adminToken)
if len(payloadB.Configs) == 0 {
t.Fatalf("expected exported configs from duplicate-prefix route, got none")
}
})
t.Run("backup import applies exported data", func(t *testing.T) {
payload := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
payload.Configs[key] = "v2"
raw, err := json.Marshal(backupImportPayload{Types: []string{"configs"}, backupExportPayload: payload})
if err != nil {
t.Fatalf("marshal import payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", bytes.NewReader(raw))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
var out response.R
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode import response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected import code 0, got %d (%s)", out.Code, out.Msg)
}
cfg, err := repo.GetConfigByName(key)
if err != nil {
t.Fatalf("query imported config: %v", err)
}
if cfg == nil || cfg.Value != "v2" {
t.Fatalf("expected imported config value v2, got %+v", cfg)
}
})
t.Run("backup restore alias applies exported data", func(t *testing.T) {
payload := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
payload.Configs[key] = "v3"
raw, err := json.Marshal(backupImportPayload{Types: []string{"configs"}, backupExportPayload: payload})
if err != nil {
t.Fatalf("marshal restore payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/restore", bytes.NewReader(raw))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
var out response.R
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode restore response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected restore code 0, got %d (%s)", out.Code, out.Msg)
}
cfg, err := repo.GetConfigByName(key)
if err != nil {
t.Fatalf("query restored config: %v", err)
}
if cfg == nil || cfg.Value != "v3" {
t.Fatalf("expected restored config value v3, got %+v", cfg)
}
})
t.Run("backup export tolerates nullable legacy tunnel chain fields", func(t *testing.T) {
now := time.Now().UnixMilli()
res, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "legacy-null-chain", 1.0, 1, "tls", 1000, now, now, 1, nil, 1)
if err != nil {
t.Fatalf("seed tunnel for nullable chain export: %v", err)
}
tunnelID, err := res.LastInsertId()
if err != nil {
t.Fatalf("read tunnel id for nullable chain export: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, ?, ?, ?, ?, ?, ?)
`, tunnelID, "1", 1, nil, nil, nil, nil); err != nil {
t.Fatalf("seed nullable chain_tunnel row: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", bytes.NewBufferString(`{"types":["tunnels"]}`))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", resp.Code)
}
var payload struct {
Version string `json:"version"`
Tunnels []struct {
ID int64 `json:"id"`
ChainTunnels []struct {
Inx int `json:"inx"`
Strategy string `json:"strategy"`
Protocol string `json:"protocol"`
} `json:"chainTunnels"`
} `json:"tunnels"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("decode tunnels backup payload: %v", err)
}
if strings.TrimSpace(payload.Version) == "" {
t.Fatalf("expected backup payload version, got empty")
}
found := false
for _, tunnel := range payload.Tunnels {
if tunnel.ID != tunnelID {
continue
}
if len(tunnel.ChainTunnels) != 1 {
t.Fatalf("expected one chain tunnel for seeded tunnel %d, got %d", tunnelID, len(tunnel.ChainTunnels))
}
if tunnel.ChainTunnels[0].Inx != 0 {
t.Fatalf("expected nullable chain inx to export as 0, got %d", tunnel.ChainTunnels[0].Inx)
}
if tunnel.ChainTunnels[0].Strategy != "" {
t.Fatalf("expected nullable chain strategy to export as empty string, got %q", tunnel.ChainTunnels[0].Strategy)
}
if tunnel.ChainTunnels[0].Protocol != "" {
t.Fatalf("expected nullable chain protocol to export as empty string, got %q", tunnel.ChainTunnels[0].Protocol)
}
found = true
break
}
if !found {
t.Fatalf("expected seeded tunnel %d in backup export", tunnelID)
}
})
}
type backupExportPayload struct {
Version string `json:"version"`
ExportedAt int64 `json:"exportedAt"`
Configs map[string]string `json:"configs"`
}
type backupImportPayload struct {
Types []string `json:"types"`
backupExportPayload
}
func exportBackupPayload(t *testing.T, router http.Handler, path, token string) backupExportPayload {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{"types":["configs"]}`))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected status 200 on %s, got %d", path, resp.Code)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read backup payload from %s: %v", path, err)
}
var payload backupExportPayload
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode backup payload from %s: %v", path, err)
}
if strings.TrimSpace(payload.Version) == "" {
var out response.R
if err := json.Unmarshal(body, &out); err == nil {
t.Fatalf("expected backup payload on %s, got envelope code=%d msg=%q", path, out.Code, out.Msg)
}
t.Fatalf("expected non-empty backup payload version on %s, body=%s", path, string(body))
}
if payload.Configs == nil {
t.Fatalf("expected configs map in backup payload on %s", path)
}
return payload
}
func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.Repository) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "contract.db")
@@ -305,7 +534,7 @@ func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
}
}
func readTableColumns(t *testing.T, db *sql.DB, table string) map[string]bool {
func readTableColumns(t *testing.T, db *store.DB, table string) map[string]bool {
t.Helper()
rows, err := db.Query("PRAGMA table_info(" + table + ")")
@@ -2,6 +2,7 @@ package contract_test
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -144,6 +145,17 @@ func TestTunnelUpdateAssignsChainPortsContract(t *testing.T) {
if outPort <= 0 {
t.Fatalf("expected out node port to be assigned, got %d", outPort)
}
var entryStrategy sql.NullString
if err := repo.DB().QueryRow(`SELECT strategy FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 1 LIMIT 1`, tunnelID).Scan(&entryStrategy); err != nil {
t.Fatalf("query entry strategy: %v", err)
}
if !entryStrategy.Valid || strings.TrimSpace(entryStrategy.String) == "" {
t.Fatalf("expected entry strategy to be non-null and non-empty")
}
if entryStrategy.String != "round" {
t.Fatalf("expected entry strategy round, got %q", entryStrategy.String)
}
}
func jsonInt(v int64) string {
+39
View File
@@ -0,0 +1,39 @@
# GOST CONNECTOR KNOWLEDGE BASE
**Generated:** Fri Feb 13 2026
## OVERVIEW
Connection initiators (clients) for various protocols in GOST forwarding.
**Stack:** Go, GOST core.
## STRUCTURE
```
connector/
├── direct/ # Direct connection
├── forward/ # Forward proxy
├── http/ # HTTP connector
├── http2/ # HTTP/2 connector
├── relay/ # Relay protocol
├── router/ # Router connector
├── serial/ # Serial port
├── sni/ # SNI routing
├── socks/ # SOCKS4/5
├── ss/ # Shadowsocks
├── sshd/ # SSH daemon
├── tcp/ # TCP connector
├── tunnel/ # Tunnel mode
└── unix/ # Unix socket
```
## CONVENTIONS
- Inherits from parent `go-gost/x/` conventions.
- Each subdir implements `Connector` interface from GOST core.
## ANTI-PATTERNS
- DO NOT EDIT generated protobuf in `go-gost/x/internal/util/grpc/proto/`.
## COMMANDS
```bash
cd go-gost
go test ./x/connector/...
```
+9 -7
View File
@@ -54,10 +54,10 @@ var needWrap = false
// SetProtocolBlock sets protocol blocking switches and recomputes wrapper need
func SetProtocolBlock(httpOn int, tlsOn int, socksOn int) {
isHttp = httpOn
isTls = tlsOn
isSocks = socksOn
needWrap = isTls+isSocks+isHttp > 0
isHttp = httpOn
isTls = tlsOn
isSocks = socksOn
needWrap = isTls+isSocks+isHttp > 0
}
type Option func(opts *options)
@@ -292,7 +292,9 @@ func (s *defaultService) Serve() error {
}
if err := s.handler.Handle(ctx, conn); err != nil {
log.Error(err)
if !errors.Is(err, net.ErrClosed) {
log.Error(err)
}
if v := xmetrics.GetCounter(xmetrics.MetricServiceHandlerErrorsCounter,
metrics.Labels{"service": s.name, "client": clientIP}); v != nil {
v.Inc()
@@ -403,12 +405,12 @@ func (s *defaultService) observeStats(ctx context.Context) {
TotalErrs: st.Get(stats.KindTotalErrs),
},
}
// 将流量累积到全局管理器,而不是立即上报
if outputBytes > 0 || inputBytes > 0 {
globalManager := GetGlobalTrafficManager()
globalManager.AddTraffic(s.name, int64(outputBytes), int64(inputBytes))
// 立即重置流量计数(因为已经记录到全局管理器中)
if xstats, ok := st.(*xstats.Stats); ok {
xstats.ResetTraffic(st.Get(stats.KindInputBytes)-inputBytes, st.Get(stats.KindOutputBytes)-outputBytes)
+30
View File
@@ -0,0 +1,30 @@
# GOST SOCKET KNOWLEDGE BASE
**Generated:** Fri Feb 13 2026
## OVERVIEW
Socket utilities and wrappers for GOST forwarding.
**Stack:** Go, GOST core.
## STRUCTURE
```
socket/
├── socket.go # Core socket interface
├── udp.go # UDP socket handling
├── packet.go # Packet framing
├── packetconn.go # Packet connection wrapper
└── ... # Additional socket utilities
```
## CONVENTIONS
- Inherits from parent `go-gost/x/` conventions.
- Low-level network primitives.
## ANTI-PATTERNS
- DO NOT EDIT generated protobuf.
## COMMANDS
```bash
cd go-gost
go test ./x/socket/...
```
+215 -6
View File
@@ -4,10 +4,17 @@ import (
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync" // 新增:用于管理连接状态的互斥锁
@@ -21,7 +28,6 @@ import (
"github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/mem"
psnet "github.com/shirou/gopsutil/v3/net"
"os"
)
// SystemInfo 系统信息结构体
@@ -85,6 +91,11 @@ type TcpPingResponse struct {
RequestId string `json:"requestId,omitempty"`
}
const (
reporterReadWait = 60 * time.Second
reporterWriteWait = 5 * time.Second
)
type WebSocketReporter struct {
url string
addr string // 保存服务器地址
@@ -237,6 +248,14 @@ func (w *WebSocketReporter) connect() error {
w.conn = conn
w.connected = true
_ = conn.SetReadDeadline(time.Now().Add(reporterReadWait))
conn.SetPingHandler(func(appData string) error {
_ = conn.SetReadDeadline(time.Now().Add(reporterReadWait))
return conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(reporterWriteWait))
})
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(reporterReadWait))
})
// 设置关闭处理器来检测连接状态
w.conn.SetCloseHandler(func(code int, text string) error {
@@ -377,7 +396,7 @@ func (w *WebSocketReporter) receiveMessages() {
}
// 设置读取超时
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
conn.SetReadDeadline(time.Now().Add(reporterReadWait))
messageType, message, err := conn.ReadMessage()
if err != nil {
@@ -466,9 +485,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
}
if cmdMsg.Type != "call" {
// TcpPing 诊断命令异步执行,避免阻塞其他命令
// 其他状态变更命令保持同步,确保顺序执行
if cmdMsg.Type == "TcpPing" {
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
go w.routeCommand(cmdMsg)
} else {
w.routeCommand(cmdMsg)
@@ -483,9 +501,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
return
}
if cmdMsg.Type != "call" {
// TcpPing 诊断命令异步执行,避免阻塞其他命令
// 其他状态变更命令保持同步,确保顺序执行
if cmdMsg.Type == "TcpPing" {
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
go w.routeCommand(cmdMsg)
} else {
w.routeCommand(cmdMsg)
@@ -579,6 +596,18 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
response.Type = "SetProtocolResponse"
needSaveConfig = true
// 升级 Agent 命令(异步执行,不需要保存配置)
case "UpgradeAgent":
err = w.handleUpgradeAgent(cmd.Data)
response.Type = "UpgradeAgentResponse"
// needSaveConfig = false (默认值)
// 回退 Agent 到旧版本
case "RollbackAgent":
err = w.handleRollbackAgent(cmd.Data)
response.Type = "RollbackAgentResponse"
// needSaveConfig = false (默认值)
default:
err = fmt.Errorf("未知命令类型: %s", cmd.Type)
response.Type = "UnknownCommandResponse"
@@ -881,6 +910,186 @@ func (w *WebSocketReporter) handleSetProtocol(data interface{}) error {
return nil
}
// sendUpgradeProgress 通过 WS 发送升级进度消息
func (w *WebSocketReporter) sendUpgradeProgress(stage string, percent int, message string) {
response := CommandResponse{
Type: "UpgradeProgress",
Success: true,
Message: message,
Data: map[string]interface{}{
"stage": stage,
"percent": percent,
},
}
w.sendResponse(response)
}
func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化数据失败: %v", err)
}
var req struct {
DownloadURL string `json:"downloadUrl"`
ChecksumURL string `json:"checksumUrl"`
}
if err := json.Unmarshal(jsonData, &req); err != nil {
return fmt.Errorf("解析升级参数失败: %v", err)
}
if strings.TrimSpace(req.DownloadURL) == "" {
return fmt.Errorf("下载地址不能为空")
}
// 替换架构占位符
downloadURL := strings.ReplaceAll(req.DownloadURL, "{ARCH}", runtime.GOARCH)
checksumURL := strings.ReplaceAll(req.ChecksumURL, "{ARCH}", runtime.GOARCH)
w.sendUpgradeProgress("downloading", 0, "开始下载升级包...")
fmt.Printf("📦 开始下载升级包: %s\n", downloadURL)
// 下载新版本二进制
const binaryPath = "/etc/flux_agent/flux_agent"
tmpPath := binaryPath + ".new"
backupPath := binaryPath + ".old"
resp, err := http.Get(downloadURL)
if err != nil {
return fmt.Errorf("下载升级包失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("下载升级包失败, HTTP状态码: %d", resp.StatusCode)
}
outFile, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("创建临时文件失败: %v", err)
}
// 带进度的下载
totalSize := resp.ContentLength
var downloaded int64
buf := make([]byte, 32*1024)
lastPercent := 0
hasher := sha256.New()
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
if _, wErr := outFile.Write(buf[:n]); wErr != nil {
outFile.Close()
os.Remove(tmpPath)
return fmt.Errorf("写入升级包失败: %v", wErr)
}
hasher.Write(buf[:n])
downloaded += int64(n)
if totalSize > 0 {
percent := int(downloaded * 100 / totalSize)
if percent-lastPercent >= 10 {
lastPercent = percent
w.sendUpgradeProgress("downloading", percent, fmt.Sprintf("下载中... %d%%", percent))
}
}
}
if readErr != nil {
if readErr == io.EOF {
break
}
outFile.Close()
os.Remove(tmpPath)
return fmt.Errorf("读取升级包失败: %v", readErr)
}
}
outFile.Close()
if downloaded == 0 {
os.Remove(tmpPath)
return fmt.Errorf("下载的升级包为空")
}
w.sendUpgradeProgress("downloading", 100, fmt.Sprintf("下载完成 (%d bytes)", downloaded))
// Checksum 校验
if checksumURL != "" {
w.sendUpgradeProgress("verifying", 0, "校验文件完整性...")
checksumResp, err := http.Get(checksumURL)
if err == nil {
defer checksumResp.Body.Close()
if checksumResp.StatusCode == http.StatusOK {
checksumBody, err := io.ReadAll(checksumResp.Body)
if err == nil {
// 格式: "<hash> <filename>" 或 "<hash>"
expectedHash := strings.TrimSpace(strings.Split(string(checksumBody), " ")[0])
actualHash := hex.EncodeToString(hasher.Sum(nil))
if !strings.EqualFold(expectedHash, actualHash) {
os.Remove(tmpPath)
return fmt.Errorf("校验失败: 期望 %s, 实际 %s", expectedHash, actualHash)
}
fmt.Printf("✅ Checksum 校验通过: %s\n", actualHash)
}
}
}
w.sendUpgradeProgress("verifying", 100, "校验通过")
}
if err := os.Chmod(tmpPath, 0755); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("设置执行权限失败: %v", err)
}
// 备份旧版本
w.sendUpgradeProgress("installing", 50, "备份旧版本...")
if _, err := os.Stat(binaryPath); err == nil {
// 复制旧文件作为备份(不用 rename,因为可能正在运行)
oldData, err := os.ReadFile(binaryPath)
if err == nil {
_ = os.WriteFile(backupPath, oldData, 0755)
fmt.Println("📦 旧版本已备份到", backupPath)
}
}
w.sendUpgradeProgress("installing", 80, "准备重启...")
fmt.Printf("✅ 升级包下载完成 (%d bytes), 准备重启...\n", downloaded)
// 执行重启脚本
// 使用 systemd-run 在独立的 transient unit 中运行重启脚本,
// 避免 systemctl stop 杀死 flux_agent cgroup 内所有进程(包括此脚本自身)导致 mv 未执行。
script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && mv %s %s && systemctl start flux_agent", tmpPath, binaryPath)
cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script)
if err := cmd.Start(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("启动重启脚本失败: %v", err)
}
w.sendUpgradeProgress("installing", 100, "重启中...")
fmt.Println("🔄 重启脚本已启动, Agent 将在 1 秒后重启...")
return nil
}
func (w *WebSocketReporter) handleRollbackAgent(data interface{}) error {
const binaryPath = "/etc/flux_agent/flux_agent"
backupPath := binaryPath + ".old"
// 检查备份文件是否存在
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
return fmt.Errorf("没有可用的备份文件,无法回退")
}
fmt.Println("🔄 开始回退到旧版本...")
// 执行回退脚本(同升级逻辑,使用 systemd-run 避免 cgroup 问题)
script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && cp %s %s && systemctl start flux_agent", backupPath, binaryPath)
cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script)
if err := cmd.Start(); err != nil {
return fmt.Errorf("启动回退脚本失败: %v", err)
}
fmt.Println("🔄 回退脚本已启动, Agent 将在 1 秒后重启...")
return nil
}
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error {
path := "config.json"
+11 -30
View File
@@ -3,6 +3,9 @@
# GitHub repo used for release downloads
REPO="Sagit-chu/flux-panel"
# 固定版本号(Release 构建时自动填充,留空则获取最新版)
PINNED_VERSION=""
# 获取系统架构
get_architecture() {
ARCH=$(uname -m)
@@ -22,16 +25,10 @@ get_architecture() {
# 安装目录
INSTALL_DIR="/etc/flux_agent"
# 识别国家(用于镜像加速)
COUNTRY=$(curl -s https://ipinfo.io/country)
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
maybe_proxy_url() {
local url="$1"
if [ "$COUNTRY" = "CN" ]; then
echo "https://gcode.hostcentral.cc/${url}"
else
echo "$url"
fi
echo "https://gcode.hostcentral.cc/${url}"
}
resolve_latest_release_tag() {
@@ -40,39 +37,19 @@ resolve_latest_release_tag() {
latest_url="https://github.com/${REPO}/releases/latest"
api_url="https://api.github.com/repos/${REPO}/releases/latest"
# 方式1:跟随重定向,取最终 URL 的最后一段作为 tag
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$latest_url" 2>/dev/null || true)
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
tag="${effective_url##*/}"
if [[ -n "$tag" && "$tag" != "latest" ]]; then
echo "$tag"
return 0
fi
# CN 环境下可尝试通过镜像访问(不影响非 CN)
if [ "$COUNTRY" = "CN" ]; then
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
tag="${effective_url##*/}"
if [[ -n "$tag" && "$tag" != "latest" ]]; then
echo "$tag"
return 0
fi
fi
# 方式2:GitHub API(无需 jq)
api_tag=$(curl -fsSL "$api_url" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
if [[ -n "$api_tag" ]]; then
echo "$api_tag"
return 0
fi
if [ "$COUNTRY" = "CN" ]; then
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
if [[ -n "$api_tag" ]]; then
echo "$api_tag"
return 0
fi
fi
return 1
}
@@ -85,6 +62,10 @@ resolve_version() {
echo "$FLUX_VERSION"
return 0
fi
if [[ -n "${PINNED_VERSION:-}" ]]; then
echo "$PINNED_VERSION"
return 0
fi
if resolve_latest_release_tag; then
return 0
+246 -59
View File
@@ -10,15 +10,13 @@ export LC_ALL=C
# GitHub repo used for release downloads
REPO="Sagit-chu/flux-panel"
COUNTRY=$(curl -s https://ipinfo.io/country)
# 固定版本号(Release 构建时自动填充,留空则获取最新版)
PINNED_VERSION=""
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
maybe_proxy_url() {
local url="$1"
if [ "$COUNTRY" = "CN" ]; then
echo "https://gcode.hostcentral.cc/${url}"
else
echo "$url"
fi
echo "https://gcode.hostcentral.cc/${url}"
}
resolve_latest_release_tag() {
@@ -27,36 +25,19 @@ resolve_latest_release_tag() {
latest_url="https://github.com/${REPO}/releases/latest"
api_url="https://api.github.com/repos/${REPO}/releases/latest"
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$latest_url" 2>/dev/null || true)
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
tag="${effective_url##*/}"
if [[ -n "$tag" && "$tag" != "latest" ]]; then
echo "$tag"
return 0
fi
if [ "$COUNTRY" = "CN" ]; then
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
tag="${effective_url##*/}"
if [[ -n "$tag" && "$tag" != "latest" ]]; then
echo "$tag"
return 0
fi
fi
api_tag=$(curl -fsSL "$api_url" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
if [[ -n "$api_tag" ]]; then
echo "$api_tag"
return 0
fi
if [ "$COUNTRY" = "CN" ]; then
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
if [[ -n "$api_tag" ]]; then
echo "$api_tag"
return 0
fi
fi
return 1
}
@@ -69,6 +50,10 @@ resolve_version() {
echo "$FLUX_VERSION"
return 0
fi
if [[ -n "${PINNED_VERSION:-}" ]]; then
echo "$PINNED_VERSION"
return 0
fi
if resolve_latest_release_tag; then
return 0
@@ -218,7 +203,8 @@ show_menu() {
echo "1. 安装面板"
echo "2. 更新面板"
echo "3. 卸载面板"
echo "4. 退出"
echo "4. 迁移到 PostgreSQL"
echo "5. 退出"
echo "==============================================="
}
@@ -247,6 +233,92 @@ upsert_env_var() {
mv "$tmp_file" "$file"
}
get_env_var() {
local key="$1"
local file="${2:-.env}"
if [[ ! -f "$file" ]]; then
return 0
fi
grep -m1 "^${key}=" "$file" | cut -d= -f2-
}
get_current_db_type() {
local db_type database_url
db_type=$(get_env_var "DB_TYPE")
database_url=$(get_env_var "DATABASE_URL")
if [[ "$db_type" == "sqlite" ]]; then
echo "sqlite"
elif [[ "$db_type" == "postgres" || "$database_url" == postgres://* || "$database_url" == postgresql://* ]]; then
echo "postgres"
else
echo "sqlite"
fi
}
wait_for_postgres_healthy() {
local pg_health
echo "🔍 检查 PostgreSQL 服务状态..."
for i in {1..90}; do
if docker ps --format "{{.Names}}" | grep -q "^flux-panel-postgres$"; then
pg_health=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-postgres 2>/dev/null || echo "unknown")
if [[ "$pg_health" == "healthy" ]]; then
echo "✅ PostgreSQL 服务健康检查通过"
return 0
elif [[ "$pg_health" == "unhealthy" ]]; then
echo "⚠️ PostgreSQL 健康状态:$pg_health"
fi
else
pg_health="not_running"
fi
if [ $i -eq 90 ]; then
echo "❌ PostgreSQL 启动超时(90秒)"
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-postgres 2>/dev/null || echo '容器不存在')"
return 1
fi
if [ $((i % 15)) -eq 1 ]; then
echo "⏳ 等待 PostgreSQL 启动... ($i/90) 状态:${pg_health:-unknown}"
fi
sleep 1
done
}
wait_for_backend_healthy() {
local backend_health
echo "🔍 检查后端服务状态..."
for i in {1..90}; do
if docker ps --format "{{.Names}}" | grep -q "^flux-panel-backend$"; then
backend_health=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo "unknown")
if [[ "$backend_health" == "healthy" ]]; then
echo "✅ 后端服务健康检查通过"
return 0
elif [[ "$backend_health" == "unhealthy" ]]; then
echo "⚠️ 后端健康状态:$backend_health"
fi
else
backend_health="not_running"
fi
if [ $i -eq 90 ]; then
echo "❌ 后端服务启动超时(90秒)"
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo '容器不存在')"
return 1
fi
if [ $((i % 15)) -eq 1 ]; then
echo "⏳ 等待后端服务启动... ($i/90) 状态:${backend_health:-unknown}"
fi
sleep 1
done
}
# 删除脚本自身
delete_self() {
echo ""
@@ -268,6 +340,33 @@ get_config_params() {
read -p "后端端口(默认 6365): " BACKEND_PORT
BACKEND_PORT=${BACKEND_PORT:-6365}
echo "请选择数据库类型:"
echo "1. SQLite(默认)"
echo "2. PostgreSQL"
read -p "数据库类型(1/2,默认 1): " DB_CHOICE
case "$DB_CHOICE" in
2)
DB_TYPE="postgres"
;;
""|1)
DB_TYPE="sqlite"
;;
*)
echo "⚠️ 输入无效,默认使用 SQLite"
DB_TYPE="sqlite"
;;
esac
POSTGRES_DB="flux_panel"
POSTGRES_USER="flux_panel"
POSTGRES_PASSWORD=$(generate_random)
if [[ "$DB_TYPE" == "postgres" ]]; then
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
else
DATABASE_URL=""
fi
# 生成JWT密钥
JWT_SECRET=$(generate_random)
}
@@ -295,10 +394,23 @@ JWT_SECRET=$JWT_SECRET
FRONTEND_PORT=$FRONTEND_PORT
BACKEND_PORT=$BACKEND_PORT
FLUX_VERSION=$RESOLVED_VERSION
DB_TYPE=$DB_TYPE
DATABASE_URL=$DATABASE_URL
POSTGRES_DB=$POSTGRES_DB
POSTGRES_USER=$POSTGRES_USER
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
EOF
echo "🚀 启动 docker 服务..."
$DOCKER_CMD up -d
if [[ "$DB_TYPE" == "postgres" ]]; then
$DOCKER_CMD up -d postgres
wait_for_postgres_healthy
$DOCKER_CMD up -d backend frontend
else
$DOCKER_CMD up -d backend frontend
fi
echo "🎉 部署完成"
echo "🌐 访问地址: http://服务器IP:$FRONTEND_PORT"
@@ -315,6 +427,12 @@ update_panel() {
echo "🔄 开始更新面板..."
check_docker
if [[ ! -f ".env" ]]; then
echo "⚠️ 未找到 .env,默认按 SQLite 模式更新"
fi
CURRENT_DB_TYPE=$(get_current_db_type)
echo "🗄️ 当前数据库类型:$CURRENT_DB_TYPE"
echo "🔍 获取最新版本号..."
LATEST_VERSION=$(resolve_latest_release_tag) || {
echo "❌ 无法获取最新版本号,更新终止"
@@ -348,49 +466,113 @@ update_panel() {
$DOCKER_CMD down
echo "⬇️ 拉取最新镜像..."
$DOCKER_CMD pull
if [[ "$CURRENT_DB_TYPE" == "postgres" ]]; then
$DOCKER_CMD pull backend frontend postgres
else
$DOCKER_CMD pull backend frontend
fi
echo "🚀 启动更新后的服务..."
$DOCKER_CMD up -d
if [[ "$CURRENT_DB_TYPE" == "postgres" ]]; then
$DOCKER_CMD up -d postgres
wait_for_postgres_healthy
$DOCKER_CMD up -d backend frontend
else
$DOCKER_CMD up -d backend frontend
fi
# 等待服务启动
echo "⏳ 等待服务启动..."
# 检查后端容器健康状态
echo "🔍 检查后端服务状态..."
for i in {1..90}; do
if docker ps --format "{{.Names}}" | grep -q "^flux-panel-backend$"; then
BACKEND_HEALTH=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo "unknown")
if [[ "$BACKEND_HEALTH" == "healthy" ]]; then
echo "✅ 后端服务健康检查通过"
break
elif [[ "$BACKEND_HEALTH" == "starting" ]]; then
# 继续等待
:
elif [[ "$BACKEND_HEALTH" == "unhealthy" ]]; then
echo "⚠️ 后端健康状态:$BACKEND_HEALTH"
fi
else
echo "⚠️ 后端容器未找到或未运行"
BACKEND_HEALTH="not_running"
fi
if [ $i -eq 90 ]; then
echo "❌ 后端服务启动超时(90秒)"
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo '容器不存在')"
echo "🛑 更新终止"
return 1
fi
# 每15秒显示一次进度
if [ $((i % 15)) -eq 1 ]; then
echo "⏳ 等待后端服务启动... ($i/90) 状态:${BACKEND_HEALTH:-unknown}"
fi
sleep 1
done
if ! wait_for_backend_healthy; then
echo "🛑 更新终止"
return 1
fi
echo "✅ 更新完成"
}
migrate_to_postgres() {
local current_db_type postgres_db postgres_user postgres_password database_url
echo "🔄 开始迁移 SQLite -> PostgreSQL..."
check_docker
if [[ ! -f ".env" ]]; then
echo "❌ 未找到 .env 文件,请先安装面板"
return 1
fi
if [[ ! -f "docker-compose.yml" ]]; then
echo "⚠️ 未找到 docker-compose.yml 文件,正在下载..."
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL"
echo "✅ docker-compose.yml 下载完成"
fi
current_db_type=$(get_current_db_type)
if [[ "$current_db_type" == "postgres" ]]; then
echo "ℹ️ 当前已使用 PostgreSQL,无需迁移"
return 0
fi
postgres_db=$(get_env_var "POSTGRES_DB")
postgres_user=$(get_env_var "POSTGRES_USER")
postgres_password=$(get_env_var "POSTGRES_PASSWORD")
postgres_db=${postgres_db:-flux_panel}
postgres_user=${postgres_user:-flux_panel}
postgres_password=${postgres_password:-$(generate_random)}
upsert_env_var ".env" "POSTGRES_DB" "$postgres_db"
upsert_env_var ".env" "POSTGRES_USER" "$postgres_user"
upsert_env_var ".env" "POSTGRES_PASSWORD" "$postgres_password"
echo "🛑 停止当前服务..."
docker stop -t 30 flux-panel-backend 2>/dev/null || true
docker stop -t 10 vite-frontend 2>/dev/null || true
echo "⏳ 等待数据同步..."
sleep 5
$DOCKER_CMD down
echo "💾 备份 SQLite 数据到当前目录..."
if ! docker run --rm -v sqlite_data:/data -v "$(pwd)":/backup alpine sh -c "cp /data/gost.db /backup/gost.db.bak"; then
echo "❌ SQLite 备份失败,迁移终止"
return 1
fi
echo "🚀 启动 PostgreSQL..."
$DOCKER_CMD up -d postgres
if ! wait_for_postgres_healthy; then
echo "🛑 PostgreSQL 未就绪,迁移终止"
return 1
fi
echo "🔄 执行 pgloader 迁移..."
if ! docker run --rm --network gost-network -v sqlite_data:/sqlite dimitri/pgloader:latest pgloader /sqlite/gost.db "postgresql://${postgres_user}:${postgres_password}@postgres:5432/${postgres_db}"; then
echo "❌ pgloader 迁移失败,迁移终止(如报 28P01,可执行 docker volume rm postgres_data 后重试)"
return 1
fi
database_url="postgresql://${postgres_user}:${postgres_password}@postgres:5432/${postgres_db}?sslmode=disable"
upsert_env_var ".env" "DB_TYPE" "postgres"
upsert_env_var ".env" "DATABASE_URL" "$database_url"
echo "🚀 启动迁移后的服务..."
$DOCKER_CMD up -d postgres backend frontend
echo "⏳ 等待服务启动..."
if ! wait_for_backend_healthy; then
echo "🛑 迁移后服务启动失败"
return 1
fi
echo "✅ SQLite -> PostgreSQL 迁移完成"
}
# 卸载功能
uninstall_panel() {
@@ -443,6 +625,11 @@ main() {
exit 0
;;
4)
migrate_to_postgres
delete_self
exit 0
;;
5)
echo "👋 退出脚本"
delete_self
exit 0
+3 -1
View File
@@ -87,6 +87,8 @@ http {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -94,4 +96,4 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
}
+1 -1
View File
@@ -40,7 +40,7 @@
"@heroui/system": "2.4.19",
"@heroui/table": "^2.2.24",
"@heroui/tabs": "^2.2.27",
"@heroui/theme": "2.4.19",
"@heroui/theme": "2.4.24",
"@heroui/use-theme": "2.1.10",
"@marsidev/react-turnstile": "^1.1.0",
"@nextui-org/system": "^2.4.6",
+83 -4
View File
@@ -1,3 +1,5 @@
import axios from "axios";
import Network from "./network";
// 登陆相关接口
@@ -41,6 +43,22 @@ export const checkNodeStatus = (nodeId?: number) => {
return Network.post("/node/check-status", params);
};
export const upgradeNode = (id: number, version?: string) =>
Network.post(
"/node/upgrade",
{ id, version: version || "" },
{ timeout: 5 * 60 * 1000 },
);
export const batchUpgradeNodes = (ids: number[], version?: string) =>
Network.post(
"/node/batch-upgrade",
{ ids, version: version || "" },
{ timeout: 15 * 60 * 1000 },
);
export const getNodeReleases = () => Network.post("/node/releases");
export const rollbackNode = (id: number) =>
Network.post("/node/rollback", { id });
// 隧道CRUD操作 - 全部使用POST请求
export const createTunnel = (data: any) => Network.post("/tunnel/create", data);
export const getTunnelList = () => Network.post("/tunnel/list");
@@ -122,6 +140,10 @@ export const updateConfigs = (configMap: Record<string, string>) =>
export const updateConfig = (name: string, value: string) =>
Network.post("/config/update-single", { name, value });
export const exportBackupData = () => Network.post("/backup/export");
export const importBackupData = (data: any) => Network.post("/backup/import", data);
export const restoreBackupData = (data: any) => Network.post("/backup/restore", data);
// 验证码相关接口
export const checkCaptcha = () => Network.post("/captcha/check");
export const generateCaptcha = () => Network.post(`/captcha/generate`);
@@ -200,9 +222,66 @@ export const createPeerShare = (data: {
allowedDomains?: string;
allowedIps?: string;
}) => Network.post("/federation/share/create", data);
export const updatePeerShare = (data: {
id: number;
name: string;
maxBandwidth: number;
expiryTime: number;
portRangeStart: number;
portRangeEnd: number;
allowedDomains: string;
allowedIps: string;
}) => Network.post("/federation/share/update", data);
export const deletePeerShare = (id: number) =>
Network.post("/federation/share/delete", { id });
export const importRemoteNode = (data: {
remoteUrl: string;
token: string;
}) => Network.post("/federation/node/import", data);
export const resetPeerShareFlow = (id: number) =>
Network.post("/federation/share/reset-flow", { id });
export const getPeerRemoteUsageList = () =>
Network.post("/federation/share/remote-usage/list");
export const importRemoteNode = (data: { remoteUrl: string; token: string }) =>
Network.post("/federation/node/import", data);
export interface BackupTypes {
users?: boolean;
nodes?: boolean;
tunnels?: boolean;
forwards?: boolean;
userTunnels?: boolean;
speedLimits?: boolean;
tunnelGroups?: boolean;
userGroups?: boolean;
permissions?: boolean;
configs?: boolean;
}
export const exportBackup = async (types: string[] = []) => {
const token = window.localStorage.getItem("token");
const baseURL = axios.defaults.baseURL || "/api/v1/";
const response = await axios.post(
`${baseURL}/backup/export`,
{ types },
{
headers: {
Authorization: token,
"Content-Type": "application/json",
},
responseType: "blob",
},
);
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, "");
link.setAttribute("download", `backup_${timestamp}.json`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
};
export const importBackup = (data: { types: string[]; [key: string]: any }) =>
Network.post("/backup/import", data);
+8 -2
View File
@@ -43,6 +43,10 @@ interface ApiResponse<T = any> {
data: T;
}
interface RequestOptions {
timeout?: number;
}
// 处理token失效的逻辑
function handleTokenExpired() {
// 清除localStorage中的token
@@ -71,6 +75,7 @@ const Network = {
get: function <T = any>(
path: string = "",
data: any = {},
options: RequestOptions = {},
): Promise<ApiResponse<T>> {
return new Promise(function (resolve) {
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
@@ -83,7 +88,7 @@ const Network = {
axios
.get(path, {
params: data,
timeout: 30000,
timeout: options.timeout ?? 30000,
headers: {
Authorization: window.localStorage.getItem("token"),
},
@@ -117,6 +122,7 @@ const Network = {
post: function <T = any>(
path: string = "",
data: any = {},
options: RequestOptions = {},
): Promise<ApiResponse<T>> {
return new Promise(function (resolve) {
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
@@ -128,7 +134,7 @@ const Network = {
axios
.post(path, data, {
timeout: 30000,
timeout: options.timeout ?? 30000,
headers: {
Authorization: window.localStorage.getItem("token"),
"Content-Type": "application/json",
+202 -3
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@heroui/button";
import { Card, CardBody, CardHeader } from "@heroui/card";
@@ -7,9 +7,10 @@ import { Spinner } from "@heroui/spinner";
import { Divider } from "@heroui/divider";
import { Switch } from "@heroui/switch";
import { Select, SelectItem } from "@heroui/select";
import { Checkbox, CheckboxGroup } from "@heroui/checkbox";
import toast from "react-hot-toast";
import { updateConfigs } from "@/api";
import { updateConfigs, exportBackup, importBackup } from "@/api";
import { SettingsIcon } from "@/components/icons";
import { isAdmin } from "@/utils/auth";
import {
@@ -130,12 +131,19 @@ export default function ConfigPage() {
useState<Record<string, string>>(initialConfigs);
const [loading, setLoading] = useState(
Object.keys(initialConfigs).length === 0,
); // 如果有缓存数据,不显示loading
);
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [originalConfigs, setOriginalConfigs] =
useState<Record<string, string>>(initialConfigs);
const [exportTypes, setExportTypes] = useState<string[]>([]);
const [importTypes, setImportTypes] = useState<string[]>([]);
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [importFileName, setImportFileName] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
// 权限检查
useEffect(() => {
if (!isAdmin()) {
@@ -331,6 +339,63 @@ export default function ConfigPage() {
}
};
const handleExport = async () => {
if (exportTypes.length === 0) {
toast.error("请至少选择一种数据类型");
return;
}
setExporting(true);
try {
await exportBackup(exportTypes);
toast.success("导出成功");
} catch {
toast.error("导出失败,请重试");
} finally {
setExporting(false);
}
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (importTypes.length === 0) {
toast.error("请先选择要导入的数据类型");
return;
}
setImportFileName(file.name);
setImporting(true);
try {
const text = await file.text();
const data = JSON.parse(text);
const response = await importBackup({
types: importTypes,
...data,
});
if (response.code === 0) {
toast.success(`导入成功: ${JSON.stringify(response.data)}`);
setImportTypes([]);
setImportFileName("");
} else {
toast.error("导入失败: " + response.msg);
}
} catch {
toast.error("导入失败,请检查文件格式");
} finally {
setImporting(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
@@ -427,6 +492,140 @@ export default function ConfigPage() {
</CardBody>
</Card>
)}
{/* 备份与恢复 */}
<Card className="mt-6 shadow-md">
<CardHeader className="pb-4">
<div className="flex justify-between items-center w-full">
<div>
<h2 className="text-xl font-semibold">数据备份与恢复</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
导出或导入系统数据,支持选择特定数据类型
</p>
</div>
</div>
</CardHeader>
<Divider />
<CardBody className="space-y-6 pt-6">
{/* 导出部分 */}
<div className="space-y-4">
<h3 className="text-lg font-medium">导出数据</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
选择要导出的数据类型,导出为 JSON 格式文件
</p>
<CheckboxGroup
classNames={{
wrapper: "gap-4",
}}
label="选择导出内容"
orientation="horizontal"
value={exportTypes}
onValueChange={(values) => setExportTypes(values as string[])}
>
<Checkbox value="users">用户</Checkbox>
<Checkbox value="nodes">节点</Checkbox>
<Checkbox value="tunnels">隧道</Checkbox>
<Checkbox value="forwards">转发</Checkbox>
<Checkbox value="userTunnels">用户隧道权限</Checkbox>
<Checkbox value="speedLimits">限速规则</Checkbox>
<Checkbox value="tunnelGroups">隧道分组</Checkbox>
<Checkbox value="userGroups">用户分组</Checkbox>
<Checkbox value="permissions">分组权限</Checkbox>
<Checkbox value="configs">系统配置</Checkbox>
</CheckboxGroup>
<div className="flex gap-3">
<Button
color="primary"
isLoading={exporting}
onPress={handleExport}
>
{exporting ? "导出中..." : "导出数据"}
</Button>
<Button
variant="bordered"
onPress={() => {
setExportTypes([
"users",
"nodes",
"tunnels",
"forwards",
"userTunnels",
"speedLimits",
"tunnelGroups",
"userGroups",
"permissions",
"configs",
]);
}}
>
全选
</Button>
<Button variant="flat" onPress={() => setExportTypes([])}>
清空
</Button>
</div>
</div>
<Divider />
{/* 导入部分 */}
<div className="space-y-4">
<h3 className="text-lg font-medium">导入数据</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
选择要导入的数据类型,支持从备份文件恢复数据
</p>
<CheckboxGroup
classNames={{
wrapper: "gap-4",
}}
label="选择导入内容"
orientation="horizontal"
value={importTypes}
onValueChange={(values) => setImportTypes(values as string[])}
>
<Checkbox value="users">用户</Checkbox>
<Checkbox value="nodes">节点</Checkbox>
<Checkbox value="tunnels">隧道</Checkbox>
<Checkbox value="forwards">转发</Checkbox>
<Checkbox value="userTunnels">用户隧道权限</Checkbox>
<Checkbox value="speedLimits">限速规则</Checkbox>
<Checkbox value="tunnelGroups">隧道分组</Checkbox>
<Checkbox value="userGroups">用户分组</Checkbox>
<Checkbox value="permissions">分组权限</Checkbox>
<Checkbox value="configs">系统配置</Checkbox>
</CheckboxGroup>
<input
ref={fileInputRef}
accept=".json"
className="hidden"
type="file"
onChange={handleFileChange}
/>
<div className="flex gap-3">
<Button
color="primary"
isLoading={importing}
variant="flat"
onPress={() => fileInputRef.current?.click()}
>
{importing ? "导入中..." : "选择文件导入"}
</Button>
{importFileName && (
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
已选择: {importFileName}
</span>
)}
</div>
</div>
</CardBody>
</Card>
</div>
);
}
+23 -21
View File
@@ -83,8 +83,9 @@ export default function IndexPage() {
if (response.code !== 0) {
toast.error(response.msg || "登录失败");
if (showCaptcha) {
setForm((prev) => ({ ...prev, captchaId: "" }));
setForm((prev) => ({ ...prev, captchaId: "" }));
}
return;
}
@@ -138,11 +139,7 @@ export default function IndexPage() {
} else {
const configResp = await getConfigByName("cloudflare_site_key");
if (
configResp.code === 0 &&
configResp.data &&
configResp.data.value
) {
if (configResp.code === 0 && configResp.data && configResp.data.value) {
setSiteKey(configResp.data.value);
setShowCaptcha(true);
} else {
@@ -241,8 +238,10 @@ export default function IndexPage() {
{showCaptcha && siteKey && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */}
<div
<div
className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter"
role="button"
tabIndex={0}
onClick={() => {
setShowCaptcha(false);
setLoading(false);
@@ -253,32 +252,35 @@ export default function IndexPage() {
setLoading(false);
}
}}
role="button"
tabIndex={0}
/>
{/* 验证码容器 */}
<div className="mb-4 relative z-50 bg-white dark:bg-zinc-900 p-6 rounded-lg shadow-xl">
<div className="mb-4 text-center text-sm font-medium text-gray-700 dark:text-gray-200">请完成安全验证</div>
<div className="mb-4 text-center text-sm font-medium text-gray-700 dark:text-gray-200">
请完成安全验证
</div>
<div className="flex justify-center">
<Turnstile
siteKey={siteKey}
onSuccess={(token) => {
setForm((prev) => ({ ...prev, captchaId: token }));
void performLogin(token);
options={{
theme: (document.documentElement.classList.contains(
"dark",
) ||
document.documentElement.getAttribute("data-theme") ===
"dark" ||
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light") as "light" | "dark" | "auto",
}}
siteKey={siteKey}
onError={() => {
toast.error("验证失败,请刷新重试");
setLoading(false);
}}
onExpire={() => {
setForm((prev) => ({ ...prev, captchaId: "" }));
setForm((prev) => ({ ...prev, captchaId: "" }));
}}
options={{
theme: (document.documentElement.classList.contains("dark") ||
document.documentElement.getAttribute("data-theme") === "dark" ||
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light") as "light" | "dark" | "auto"
onSuccess={(token) => {
setForm((prev) => ({ ...prev, captchaId: token }));
void performLogin(token);
}}
/>
</div>
+593 -261
View File
@@ -16,6 +16,7 @@ import { Spinner } from "@heroui/spinner";
import { Alert } from "@heroui/alert";
import { Progress } from "@heroui/progress";
import { Accordion, AccordionItem } from "@heroui/accordion";
import { Select, SelectItem } from "@heroui/select";
import { Checkbox } from "@heroui/checkbox";
import toast from "react-hot-toast";
import axios from "axios";
@@ -45,6 +46,10 @@ import {
getNodeInstallCommand,
updateNodeOrder,
batchDeleteNodes,
upgradeNode,
batchUpgradeNodes,
getNodeReleases,
rollbackNode,
} from "@/api";
interface Node {
@@ -65,6 +70,7 @@ interface Node {
status: number;
isRemote?: number;
remoteUrl?: string;
syncError?: string;
connectionStatus: "online" | "offline";
systemInfo?: {
cpuUsage: number;
@@ -76,6 +82,8 @@ interface Node {
uptime: number;
} | null;
copyLoading?: boolean;
upgradeLoading?: boolean;
rollbackLoading?: boolean;
}
interface NodeForm {
@@ -117,7 +125,12 @@ const SortableItem = ({
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<div
ref={setNodeRef}
style={style}
{...attributes}
className="overflow-hidden"
>
{children(listeners)}
</div>
);
@@ -164,6 +177,29 @@ export default function NodePage() {
const [installCommand, setInstallCommand] = useState("");
const [currentNodeName, setCurrentNodeName] = useState("");
// 升级相关状态
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
const [upgradeTarget, setUpgradeTarget] = useState<"single" | "batch">(
"single",
);
const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState<number | null>(
null,
);
const [releases, setReleases] = useState<
Array<{
version: string;
name: string;
publishedAt: string;
prerelease: boolean;
}>
>([]);
const [releasesLoading, setReleasesLoading] = useState(false);
const [selectedVersion, setSelectedVersion] = useState("");
const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);
const [upgradeProgress, setUpgradeProgress] = useState<
Record<number, { stage: string; percent: number; message: string }>
>({});
const websocketRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
@@ -219,7 +255,12 @@ export default function NodePage() {
const nodesData: Node[] = (res.data || []).map((node: any) => ({
...node,
inx: node.inx ?? 0,
connectionStatus: node.status === 1 ? "online" : "offline",
connectionStatus: node.syncError
? "offline"
: node.status === 1
? "online"
: "offline",
syncError: node.syncError || undefined,
systemInfo: null,
copyLoading: false,
}));
@@ -423,6 +464,26 @@ export default function NodePage() {
return node;
}),
);
} else if (type === "upgrade_progress") {
try {
const progressData =
typeof messageData === "string"
? JSON.parse(messageData)
: messageData;
if (progressData?.data) {
setUpgradeProgress((prev) => ({
...prev,
[nodeId]: {
stage: progressData.data.stage || "",
percent: progressData.data.percent || 0,
message: progressData.message || "",
},
}));
}
} catch {
// ignore parse errors
}
}
};
@@ -768,6 +829,106 @@ export default function NodePage() {
}
};
// 打开版本选择弹窗
const openUpgradeModal = async (
target: "single" | "batch",
nodeId?: number,
) => {
setUpgradeTarget(target);
setUpgradeTargetNodeId(nodeId || null);
setSelectedVersion("");
setUpgradeModalOpen(true);
setReleasesLoading(true);
try {
const res = await getNodeReleases();
if (res.code === 0 && Array.isArray(res.data)) {
setReleases(res.data);
} else {
toast.error(res.msg || "获取版本列表失败");
}
} catch {
toast.error("获取版本列表失败");
} finally {
setReleasesLoading(false);
}
};
// 确认升级(从版本弹窗)
const handleConfirmUpgrade = async () => {
const version = selectedVersion || undefined;
if (upgradeTarget === "single" && upgradeTargetNodeId) {
setUpgradeModalOpen(false);
// Find the node
const node = nodeList.find((n) => n.id === upgradeTargetNodeId);
if (!node) return;
setNodeList((prev) =>
prev.map((n) =>
n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n,
),
);
try {
const res = await upgradeNode(upgradeTargetNodeId, version);
if (res.code === 0) {
toast.success(`节点升级命令已发送,节点将自动重启`);
} else {
toast.error(res.msg || "升级失败");
}
} catch {
toast.error("网络错误,请重试");
} finally {
setNodeList((prev) =>
prev.map((n) =>
n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n,
),
);
}
} else if (upgradeTarget === "batch") {
setBatchUpgradeLoading(true);
setUpgradeModalOpen(false);
try {
const res = await batchUpgradeNodes(Array.from(selectedIds), version);
if (res.code === 0) {
toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);
} else {
toast.error(res.msg || "批量升级失败");
}
} catch {
toast.error("网络错误,请重试");
} finally {
setBatchUpgradeLoading(false);
}
}
};
// 回退节点
const handleRollbackNode = async (node: Node) => {
setNodeList((prev) =>
prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)),
);
try {
const res = await rollbackNode(node.id);
if (res.code === 0) {
toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);
} else {
toast.error(res.msg || "回退失败");
}
} catch {
toast.error("网络错误,请重试");
} finally {
setNodeList((prev) =>
prev.map((n) =>
n.id === node.id ? { ...n, rollbackLoading: false } : n,
),
);
}
};
// 提交表单
const handleSubmit = async () => {
if (!validateForm()) return;
@@ -1046,6 +1207,15 @@ export default function NodePage() {
<Button size="sm" variant="flat" onPress={deselectAll}>
清空
</Button>
<Button
color="warning"
isLoading={batchUpgradeLoading}
size="sm"
variant="flat"
onPress={() => openUpgradeModal("batch")}
>
升级
</Button>
<Button
color="danger"
size="sm"
@@ -1114,279 +1284,357 @@ export default function NodePage() {
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedNodes.map((node) => (
<SortableItem key={node.id} id={node.id}>
{(listeners) => (
<Card
key={node.id}
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
>
<CardHeader className="pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex items-center gap-2 flex-1 min-w-0">
{selectMode && (
<Checkbox
isSelected={selectedIds.has(node.id)}
onValueChange={() => toggleSelect(node.id)}
/>
)}
<h3 className="font-semibold text-foreground truncate text-sm">
{node.name}
</h3>
</div>
<div className="flex items-center gap-1.5 ml-2">
{node.isRemote === 1 && (
{sortedNodes.map((node) => {
const isRemoteNode = node.isRemote === 1;
return (
<SortableItem key={node.id} id={node.id}>
{(listeners) => (
<Card
key={node.id}
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
>
<CardHeader className="pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex items-center gap-2 flex-1 min-w-0">
{selectMode && (
<Checkbox
isSelected={selectedIds.has(node.id)}
onValueChange={() => toggleSelect(node.id)}
/>
)}
<h3 className="font-semibold text-foreground truncate text-sm">
{node.name}
</h3>
</div>
<div className="flex items-center gap-1.5 ml-2">
<div
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
{...listeners}
style={{ touchAction: "none" }}
title="拖拽排序"
>
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
</svg>
</div>
{isRemoteNode && (
<Chip
className="text-xs"
color="secondary"
size="sm"
variant="flat"
>
远程
</Chip>
)}
<Chip
className="text-xs"
color="secondary"
color={
node.connectionStatus === "online"
? "success"
: "danger"
}
size="sm"
variant="flat"
>
远程
{node.connectionStatus === "online"
? "在线"
: "离线"}
</Chip>
</div>
</div>
</CardHeader>
<CardBody className="pt-0 pb-3">
{isRemoteNode && node.syncError && (
<div className="mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
{node.syncError === "provider_share_deleted"
? "提供方已删除该分享"
: node.syncError === "provider_share_disabled"
? "提供方已禁用该分享"
: node.syncError === "provider_share_expired"
? "提供方分享已过期"
: `远程同步失败: ${node.syncError}`}
</div>
)}
{/* 基础信息 */}
<div className="space-y-2 mb-4">
<div className="flex justify-between items-center text-sm min-w-0">
<span className="text-default-600 flex-shrink-0">
IP
</span>
<div className="text-right text-xs min-w-0 flex-1 ml-2">
{node.serverIpV4?.trim() ||
node.serverIpV6?.trim() ? (
<div className="space-y-0.5">
{node.serverIpV4?.trim() && (
<span
className="font-mono truncate block"
title={node.serverIpV4.trim()}
>
{node.serverIpV4.trim()}
</span>
)}
{node.serverIpV6?.trim() && (
<span
className="font-mono truncate block"
title={node.serverIpV6.trim()}
>
{node.serverIpV6.trim()}
</span>
)}
</div>
) : (
<span
className="font-mono truncate block"
title={node.serverIp.trim()}
>
{node.serverIp.trim()}
</span>
)}
</div>
</div>
{!isRemoteNode && (
<>
<div className="flex justify-between text-sm">
<span className="text-default-600">版本</span>
<span className="text-xs">
{node.version || "未知"}
</span>
</div>
{upgradeProgress[node.id] &&
upgradeProgress[node.id].percent < 100 && (
<div className="mt-1">
<Progress
showValueLabel
aria-label="升级进度"
color="warning"
label={upgradeProgress[node.id].message}
size="sm"
value={upgradeProgress[node.id].percent}
/>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-default-600">
开机时间
</span>
<span className="text-xs">
{node.connectionStatus === "online" &&
node.systemInfo
? formatUptime(node.systemInfo.uptime)
: "-"}
</span>
</div>
</>
)}
</div>
{!isRemoteNode && (
<>
{/* 系统监控 */}
<div className="space-y-3 mb-4">
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span>CPU</span>
<span className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
: "-"}
</span>
</div>
<Progress
aria-label="CPU使用率"
color={getProgressColor(
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.cpuUsage
: 0,
node.connectionStatus !== "online",
)}
size="sm"
value={
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.cpuUsage
: 0
}
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>内存</span>
<span className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
: "-"}
</span>
</div>
<Progress
aria-label="内存使用率"
color={getProgressColor(
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.memoryUsage
: 0,
node.connectionStatus !== "online",
)}
size="sm"
value={
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.memoryUsage
: 0
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">
上传
</div>
<div className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? formatSpeed(
node.systemInfo.uploadSpeed,
)
: "-"}
</div>
</div>
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">
下载
</div>
<div className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? formatSpeed(
node.systemInfo.downloadSpeed,
)
: "-"}
</div>
</div>
</div>
{/* 流量统计 */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
<div className="text-primary-600 dark:text-primary-400 mb-0.5">
↑ 上行流量
</div>
<div className="font-mono text-primary-700 dark:text-primary-300">
{node.connectionStatus === "online" &&
node.systemInfo
? formatTraffic(
node.systemInfo.uploadTraffic,
)
: "-"}
</div>
</div>
<div className="text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
<div className="text-success-600 dark:text-success-400 mb-0.5">
↓ 下行流量
</div>
<div className="font-mono text-success-700 dark:text-success-300">
{node.connectionStatus === "online" &&
node.systemInfo
? formatTraffic(
node.systemInfo.downloadTraffic,
)
: "-"}
</div>
</div>
</div>
</div>
</>
)}
{/* 操作按钮 */}
<div className="space-y-1.5">
{!isRemoteNode && (
<div className="grid grid-cols-3 gap-1.5">
<Button
className="min-h-8"
color="success"
isLoading={node.copyLoading}
size="sm"
variant="flat"
onPress={() => handleCopyInstallCommand(node)}
>
安装
</Button>
<Button
className="min-h-8"
color="warning"
isDisabled={
node.connectionStatus !== "online"
}
isLoading={node.upgradeLoading}
size="sm"
variant="flat"
onPress={() =>
openUpgradeModal("single", node.id)
}
>
升级
</Button>
<Button
className="min-h-8"
color="secondary"
isDisabled={
node.connectionStatus !== "online"
}
isLoading={node.rollbackLoading}
size="sm"
variant="flat"
onPress={() => handleRollbackNode(node)}
>
回退
</Button>
</div>
)}
<div
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
{...listeners}
style={{ touchAction: "none" }}
title="拖拽排序"
className={`grid gap-1.5 ${isRemoteNode ? "grid-cols-1" : "grid-cols-2"}`}
>
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
</svg>
</div>
<Chip
className="text-xs"
color={
node.connectionStatus === "online"
? "success"
: "danger"
}
size="sm"
variant="flat"
>
{node.connectionStatus === "online"
? "在线"
: "离线"}
</Chip>
</div>
</div>
</CardHeader>
<CardBody className="pt-0 pb-3">
{/* 基础信息 */}
<div className="space-y-2 mb-4">
<div className="flex justify-between items-center text-sm min-w-0">
<span className="text-default-600 flex-shrink-0">
IP
</span>
<div className="text-right text-xs min-w-0 flex-1 ml-2">
{node.serverIpV4?.trim() ||
node.serverIpV6?.trim() ? (
<div className="space-y-0.5">
{node.serverIpV4?.trim() && (
<span
className="font-mono truncate block"
title={node.serverIpV4.trim()}
>
{node.serverIpV4.trim()}
</span>
)}
{node.serverIpV6?.trim() && (
<span
className="font-mono truncate block"
title={node.serverIpV6.trim()}
>
{node.serverIpV6.trim()}
</span>
)}
</div>
) : (
<span
className="font-mono truncate block"
title={node.serverIp.trim()}
{!isRemoteNode && (
<Button
className="min-h-8"
color="primary"
size="sm"
variant="flat"
onPress={() => handleEdit(node)}
>
{node.serverIp.trim()}
</span>
编辑
</Button>
)}
</div>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">版本</span>
<span className="text-xs">
{node.version || "未知"}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">开机时间</span>
<span className="text-xs">
{node.connectionStatus === "online" &&
node.systemInfo
? formatUptime(node.systemInfo.uptime)
: "-"}
</span>
</div>
</div>
{/* 系统监控 */}
<div className="space-y-3 mb-4">
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span>CPU</span>
<span className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
: "-"}
</span>
</div>
<Progress
aria-label="CPU使用率"
color={getProgressColor(
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.cpuUsage
: 0,
node.connectionStatus !== "online",
)}
<Button
className="min-h-8"
color="danger"
size="sm"
value={
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.cpuUsage
: 0
}
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>内存</span>
<span className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
: "-"}
</span>
</div>
<Progress
aria-label="内存使用率"
color={getProgressColor(
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.memoryUsage
: 0,
node.connectionStatus !== "online",
)}
size="sm"
value={
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.memoryUsage
: 0
}
/>
variant="flat"
onPress={() => handleDelete(node)}
>
删除
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">
上传
</div>
<div className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? formatSpeed(node.systemInfo.uploadSpeed)
: "-"}
</div>
</div>
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">
下载
</div>
<div className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? formatSpeed(node.systemInfo.downloadSpeed)
: "-"}
</div>
</div>
</div>
{/* 流量统计 */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
<div className="text-primary-600 dark:text-primary-400 mb-0.5">
↑ 上行流量
</div>
<div className="font-mono text-primary-700 dark:text-primary-300">
{node.connectionStatus === "online" &&
node.systemInfo
? formatTraffic(node.systemInfo.uploadTraffic)
: "-"}
</div>
</div>
<div className="text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
<div className="text-success-600 dark:text-success-400 mb-0.5">
↓ 下行流量
</div>
<div className="font-mono text-success-700 dark:text-success-300">
{node.connectionStatus === "online" &&
node.systemInfo
? formatTraffic(
node.systemInfo.downloadTraffic,
)
: "-"}
</div>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="space-y-1.5">
<div className="flex gap-1.5">
<Button
className="flex-1 min-h-8"
color="success"
isDisabled={node.isRemote === 1}
isLoading={node.copyLoading}
size="sm"
variant="flat"
onPress={() => handleCopyInstallCommand(node)}
>
安装
</Button>
<Button
className="flex-1 min-h-8"
color="primary"
isDisabled={node.isRemote === 1}
size="sm"
variant="flat"
onPress={() => handleEdit(node)}
>
编辑
</Button>
<Button
className="flex-1 min-h-8"
color="danger"
size="sm"
variant="flat"
onPress={() => handleDelete(node)}
>
删除
</Button>
</div>
</div>
</CardBody>
</Card>
)}
</SortableItem>
))}
</CardBody>
</Card>
)}
</SortableItem>
);
})}
</div>
</SortableContext>
</DndContext>
@@ -1817,6 +2065,90 @@ export default function NodePage() {
</ModalContent>
</Modal>
{/* 版本选择升级模态框 */}
<Modal
backdrop="blur"
isOpen={upgradeModalOpen}
placement="center"
scrollBehavior="outside"
size="md"
onOpenChange={setUpgradeModalOpen}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">
{upgradeTarget === "batch"
? `批量升级 (${selectedIds.size} 个节点)`
: "升级节点"}
</h2>
</ModalHeader>
<ModalBody>
{releasesLoading ? (
<div className="flex justify-center py-8">
<Spinner size="lg" />
</div>
) : (
<div className="space-y-4">
<Select
label="选择版本"
placeholder="留空则使用最新版本"
selectedKeys={selectedVersion ? [selectedVersion] : []}
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as string;
setSelectedVersion(selected || "");
}}
>
{releases.map((r) => (
<SelectItem key={r.version} textValue={r.version}>
<div className="flex justify-between items-center">
<span>{r.version}</span>
<span className="text-xs text-default-400">
{r.publishedAt
? new Date(r.publishedAt).toLocaleDateString()
: ""}
{r.prerelease && (
<Chip
className="ml-1"
color="warning"
size="sm"
variant="flat"
>
预览
</Chip>
)}
</span>
</div>
</SelectItem>
))}
</Select>
<p className="text-sm text-default-500">
{selectedVersion
? `将升级到版本 ${selectedVersion}`
: "未选择版本,将自动使用最新稳定版"}
</p>
</div>
)}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button
color="warning"
isDisabled={releasesLoading}
onPress={handleConfirmUpgrade}
>
确认升级
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 批量删除确认模态框 */}
<Modal
backdrop="blur"
+508 -31
View File
@@ -12,12 +12,16 @@ import {
} from "@heroui/modal";
import { Select, SelectItem } from "@heroui/select";
import { toast } from "react-hot-toast";
import {
getNodeList,
createPeerShare,
getPeerShareList,
deletePeerShare,
resetPeerShareFlow,
getPeerRemoteUsageList,
importRemoteNode,
updatePeerShare,
} from "@/api";
interface Node {
@@ -31,22 +35,66 @@ interface PeerShare {
name: string;
token: string;
maxBandwidth: number;
currentFlow: number;
expiryTime: number;
portRangeStart: number;
portRangeEnd: number;
isActive: number;
allowedDomains?: string;
allowedIps?: string;
usedPorts?: number[];
usedPortDetails?: Array<{
runtimeId: number;
port: number;
role: string;
protocol: string;
resourceKey: string;
applied: number;
updatedTime: number;
}>;
activeRuntimeNum?: number;
}
interface RemoteUsageBinding {
bindingId: number;
tunnelId: number;
tunnelName: string;
chainType: number;
hopInx: number;
allocatedPort: number;
resourceKey: string;
remoteBindingId: string;
updatedTime: number;
}
interface RemoteUsageNode {
nodeId: number;
nodeName: string;
remoteUrl: string;
shareId: number;
portRangeStart: number;
portRangeEnd: number;
maxBandwidth: number;
currentFlow: number;
usedPorts: number[];
bindings: RemoteUsageBinding[];
activeBindingNum: number;
syncError?: string;
}
export default function PanelSharingPage() {
const [selectedTab, setSelectedTab] = useState("my-shares");
const [shares, setShares] = useState<PeerShare[]>([]);
const [remoteUsageNodes, setRemoteUsageNodes] = useState<RemoteUsageNode[]>(
[],
);
const [nodes, setNodes] = useState<Node[]>([]);
const [loading, setLoading] = useState(false);
const [remoteUsageLoading, setRemoteUsageLoading] = useState(false);
// Modals
const [createShareOpen, setCreateShareOpen] = useState(false);
const [editShareOpen, setEditShareOpen] = useState(false);
const [importNodeOpen, setImportNodeOpen] = useState(false);
// Forms
@@ -66,10 +114,22 @@ export default function PanelSharingPage() {
token: "",
});
const [editForm, setEditForm] = useState({
id: 0,
name: "",
maxBandwidth: 0,
expiryTime: 0,
portRangeStart: 10000,
portRangeEnd: 20000,
allowedDomains: "",
allowedIps: "",
});
const loadShares = useCallback(async () => {
setLoading(true);
try {
const res = await getPeerShareList();
if (res.code === 0) {
setShares(res.data || []);
} else {
@@ -83,10 +143,12 @@ export default function PanelSharingPage() {
const loadNodes = useCallback(async () => {
try {
const res = await getNodeList();
if (res.code === 0) {
const localNodes: Node[] = (res.data || []).filter(
(node: Node) => (node?.isRemote ?? 0) !== 1,
);
setNodes(localNodes);
setShareForm((prev) => {
if (!prev.nodeId) {
@@ -95,6 +157,7 @@ export default function PanelSharingPage() {
const hasSelectedNode = localNodes.some(
(node: Node) => String(node.id) === prev.nodeId,
);
return hasSelectedNode ? prev : { ...prev, nodeId: "" };
});
}
@@ -103,21 +166,49 @@ export default function PanelSharingPage() {
}
}, []);
const loadRemoteUsage = useCallback(async () => {
setRemoteUsageLoading(true);
try {
const res = await getPeerRemoteUsageList();
if (res.code === 0) {
setRemoteUsageNodes(res.data || []);
} else {
toast.error(res.msg || "加载远程占用端口失败");
}
} finally {
setRemoteUsageLoading(false);
}
}, []);
useEffect(() => {
if (selectedTab === "my-shares") {
loadShares();
loadNodes();
return;
}
}, [selectedTab, loadShares, loadNodes]);
if (selectedTab === "remote-nodes") {
loadRemoteUsage();
}
}, [selectedTab, loadShares, loadNodes, loadRemoteUsage]);
const handleCreateShare = async () => {
if (!shareForm.name || !shareForm.nodeId) {
toast.error("请填写必要信息");
return;
}
const nodeId = parseInt(shareForm.nodeId, 10);
if (Number.isNaN(nodeId) || !nodes.some((node) => node.id === nodeId)) {
toast.error("仅可选择本地节点");
return;
}
if (shareForm.maxBandwidth < 0) {
toast.error("流量上限不能为负数");
return;
}
try {
@@ -126,13 +217,14 @@ export default function PanelSharingPage() {
const res = await createPeerShare({
name: shareForm.name,
nodeId,
maxBandwidth: shareForm.maxBandwidth * 1024 * 1024 * 1024,
maxBandwidth: Math.max(0, shareForm.maxBandwidth) * 1024 * 1024 * 1024,
expiryTime: shareForm.expiryDays === 0 ? 0 : expiryTime,
portRangeStart: shareForm.portRangeStart,
portRangeEnd: shareForm.portRangeEnd,
allowedDomains: shareForm.allowedDomains,
allowedIps: shareForm.allowedIps,
});
if (res.code === 0) {
toast.success("创建成功");
setCreateShareOpen(false);
@@ -148,6 +240,7 @@ export default function PanelSharingPage() {
const handleDeleteShare = async (id: number) => {
try {
const res = await deletePeerShare(id);
if (res.code === 0) {
toast.success("删除成功");
loadShares();
@@ -159,26 +252,97 @@ export default function PanelSharingPage() {
}
};
const handleResetShareFlow = async (id: number) => {
try {
const res = await resetPeerShareFlow(id);
if (res.code === 0) {
toast.success("共享流量已重置");
loadShares();
} else {
toast.error(res.msg || "重置流量失败");
}
} catch {
toast.error("网络错误");
}
};
const openEditShare = (share: PeerShare) => {
setEditForm({
id: share.id,
name: share.name,
maxBandwidth:
share.maxBandwidth > 0
? Math.round(share.maxBandwidth / (1024 * 1024 * 1024))
: 0,
expiryTime: share.expiryTime,
portRangeStart: share.portRangeStart,
portRangeEnd: share.portRangeEnd,
allowedDomains: share.allowedDomains || "",
allowedIps: share.allowedIps || "",
});
setEditShareOpen(true);
};
const handleEditShare = async () => {
if (!editForm.name) {
toast.error("名称不能为空");
return;
}
if (editForm.maxBandwidth < 0) {
toast.error("流量上限不能为负数");
return;
}
try {
const res = await updatePeerShare({
id: editForm.id,
name: editForm.name,
maxBandwidth: Math.max(0, editForm.maxBandwidth) * 1024 * 1024 * 1024,
expiryTime: editForm.expiryTime,
portRangeStart: editForm.portRangeStart,
portRangeEnd: editForm.portRangeEnd,
allowedDomains: editForm.allowedDomains,
allowedIps: editForm.allowedIps,
});
if (res.code === 0) {
toast.success("编辑成功");
setEditShareOpen(false);
loadShares();
} else {
toast.error(res.msg || "编辑失败");
}
} catch {
toast.error("网络错误");
}
};
const handleImportNode = async () => {
if (!importForm.remoteUrl || !importForm.token) {
toast.error("请填写完整信息");
return;
}
try {
// Automatically add http/https if missing
let url = importForm.remoteUrl.trim();
if (!url.startsWith("http")) {
url = "http://" + url;
}
const res = await importRemoteNode({
remoteUrl: url,
token: importForm.token.trim(),
});
if (res.code === 0) {
toast.success("导入成功,请前往节点列表查看");
setImportNodeOpen(false);
setImportForm({ remoteUrl: "", token: "" });
loadRemoteUsage();
} else {
toast.error(res.msg || "导入失败");
}
@@ -192,6 +356,29 @@ export default function PanelSharingPage() {
toast.success("Token已复制");
};
const formatFlowGB = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
}
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB";
if (bytes < 1024 * 1024 * 1024)
return (bytes / (1024 * 1024)).toFixed(2) + " MB";
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB";
};
const formatChainType = (chainType: number, hopInx: number) => {
if (chainType === 2) {
return `中继跳点 #${hopInx}`;
}
if (chainType === 3) {
return "出口节点";
}
return "未知链路";
};
return (
<div className="p-4 md:p-6 space-y-6">
<div className="flex justify-between items-center">
@@ -199,6 +386,7 @@ export default function PanelSharingPage() {
</div>
<Tabs
disableCursorAnimation
aria-label="Options"
selectedKey={selectedTab}
onSelectionChange={(k) => setSelectedTab(k as string)}
@@ -207,11 +395,14 @@ export default function PanelSharingPage() {
<Card>
<CardBody>
<div className="mb-4">
<Button color="primary" onPress={() => setCreateShareOpen(true)}>
<Button
color="primary"
onPress={() => setCreateShareOpen(true)}
>
创建分享
</Button>
</div>
{loading ? (
<div className="text-center py-10 text-gray-500">加载中...</div>
) : shares.length === 0 ? (
@@ -219,19 +410,88 @@ export default function PanelSharingPage() {
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{shares.map((share) => (
<Card key={share.id} className="border border-divider shadow-sm">
<Card
key={share.id}
className="border border-divider shadow-sm"
>
<CardHeader className="flex justify-between">
<h3 className="font-bold">{share.name}</h3>
<Button size="sm" color="danger" variant="flat" onPress={() => handleDeleteShare(share.id)}>删除</Button>
<div className="flex gap-2">
<Button
size="sm"
variant="flat"
onPress={() => openEditShare(share)}
>
编辑
</Button>
<Button
size="sm"
variant="flat"
onPress={() => handleResetShareFlow(share.id)}
>
重置流量
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onPress={() => handleDeleteShare(share.id)}
>
删除
</Button>
</div>
</CardHeader>
<CardBody className="text-sm space-y-2">
<p>端口范围: {share.portRangeStart} - {share.portRangeEnd}</p>
{share.allowedDomains && <p>允许域名: {share.allowedDomains}</p>}
{share.allowedIps && <p>允许API IP: {share.allowedIps}</p>}
<p>过期时间: {share.expiryTime === 0 ? "永久" : new Date(share.expiryTime).toLocaleDateString()}</p>
<p>
端口范围: {share.portRangeStart} -{" "}
{share.portRangeEnd}
</p>
<p>
流量上限:{" "}
{share.maxBandwidth > 0
? formatFlowGB(share.maxBandwidth)
: "不限制"}
</p>
<p>当前流量: {formatFlowGB(share.currentFlow || 0)}</p>
<p>
远程占用端口:{" "}
{share.usedPorts && share.usedPorts.length > 0
? share.usedPorts.join(", ")
: "暂无"}
</p>
{share.usedPortDetails &&
share.usedPortDetails.length > 0 && (
<div className="flex flex-wrap gap-2">
{share.usedPortDetails.map((item) => (
<span
key={item.runtimeId}
className="text-xs rounded-full px-2 py-1 bg-default-100"
>
{item.port} / {item.role || "reserved"}
</span>
))}
</div>
)}
{share.allowedDomains && (
<p>允许域名: {share.allowedDomains}</p>
)}
{share.allowedIps && (
<p>允许API IP: {share.allowedIps}</p>
)}
<p>
过期时间:{" "}
{share.expiryTime === 0
? "永久"
: new Date(share.expiryTime).toLocaleDateString()}
</p>
<div className="flex gap-2">
<Input readOnly size="sm" value={share.token} />
<Button size="sm" onPress={() => copyToken(share.token)}>复制</Button>
<Button
size="sm"
onPress={() => copyToken(share.token)}
>
复制
</Button>
</div>
</CardBody>
</Card>
@@ -245,14 +505,93 @@ export default function PanelSharingPage() {
<Card>
<CardBody>
<div className="mb-4">
<Button color="secondary" onPress={() => setImportNodeOpen(true)}>
<Button
color="secondary"
onPress={() => setImportNodeOpen(true)}
>
导入远程节点
</Button>
</div>
<div className="text-center py-10 text-gray-500">
<p>已导入的节点将显示在“节点管理”页面,带有“远程”标记。</p>
<p className="mt-2">请使用其创建隧道。</p>
</div>
{remoteUsageLoading ? (
<div className="text-center py-10 text-gray-500">加载中...</div>
) : remoteUsageNodes.length === 0 ? (
<div className="text-center py-10 text-gray-500">
<p>暂无远程节点占用记录。</p>
<p className="mt-2">
导入远程节点并创建隧道后,这里会显示远端端口占用情况。
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{remoteUsageNodes.map((node) => (
<Card
key={node.nodeId}
className="border border-divider shadow-sm"
>
<CardHeader className="flex justify-between">
<h3 className="font-bold">{node.nodeName}</h3>
<span className="text-xs text-default-500">
绑定 {node.activeBindingNum || 0}
</span>
</CardHeader>
<CardBody className="text-sm space-y-2">
{node.syncError && (
<div className="px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
{node.syncError === "provider_share_deleted"
? "提供方已删除该分享"
: node.syncError === "provider_share_disabled"
? "提供方已禁用该分享"
: node.syncError === "provider_share_expired"
? "提供方分享已过期"
: `远程同步失败: ${node.syncError}`}
</div>
)}
{node.remoteUrl && <p>远程地址: {node.remoteUrl}</p>}
<p>共享ID: {node.shareId || "-"}</p>
<p>
端口范围:{" "}
{node.portRangeStart > 0 && node.portRangeEnd > 0
? `${node.portRangeStart} - ${node.portRangeEnd}`
: "-"}
</p>
<p>
共享流量:{" "}
{node.maxBandwidth > 0
? `${formatFlowGB(node.currentFlow || 0)} / ${formatFlowGB(node.maxBandwidth)}`
: `${formatFlowGB(node.currentFlow || 0)} / 不限制`}
</p>
<p>
远端占用端口:{" "}
{node.usedPorts && node.usedPorts.length > 0
? node.usedPorts.join(", ")
: "暂无"}
</p>
{node.bindings && node.bindings.length > 0 && (
<div className="space-y-1 pt-1">
{node.bindings.map((binding) => (
<p
key={binding.bindingId}
className="text-xs text-default-600"
>
隧道{" "}
{binding.tunnelName || `#${binding.tunnelId}`}
{" · "}
端口 {binding.allocatedPort}
{" · "}
{formatChainType(
binding.chainType,
binding.hopInx,
)}
</p>
))}
</div>
)}
</CardBody>
</Card>
))}
</div>
)}
</CardBody>
</Card>
</Tab>
@@ -267,13 +606,17 @@ export default function PanelSharingPage() {
label="名称"
placeholder="备注名称"
value={shareForm.name}
onChange={(e) => setShareForm({ ...shareForm, name: e.target.value })}
onChange={(e) =>
setShareForm({ ...shareForm, name: e.target.value })
}
/>
<Select
label="选择节点"
placeholder="选择要分享的本地节点"
selectedKeys={shareForm.nodeId ? [shareForm.nodeId] : []}
onChange={(e) => setShareForm({ ...shareForm, nodeId: e.target.value })}
onChange={(e) =>
setShareForm({ ...shareForm, nodeId: e.target.value })
}
>
{nodes.map((node) => (
<SelectItem key={node.id} textValue={node.name}>
@@ -286,40 +629,168 @@ export default function PanelSharingPage() {
label="起始端口"
type="number"
value={shareForm.portRangeStart.toString()}
onChange={(e) => setShareForm({ ...shareForm, portRangeStart: parseInt(e.target.value) })}
onChange={(e) =>
setShareForm({
...shareForm,
portRangeStart: parseInt(e.target.value),
})
}
/>
<Input
label="结束端口"
type="number"
value={shareForm.portRangeEnd.toString()}
onChange={(e) => setShareForm({ ...shareForm, portRangeEnd: parseInt(e.target.value) })}
onChange={(e) =>
setShareForm({
...shareForm,
portRangeEnd: parseInt(e.target.value),
})
}
/>
</div>
<Input
description="0 表示永久"
label="有效期 (天)"
type="number"
description="0 表示永久"
value={shareForm.expiryDays.toString()}
onChange={(e) => setShareForm({ ...shareForm, expiryDays: parseInt(e.target.value) })}
onChange={(e) =>
setShareForm({
...shareForm,
expiryDays: parseInt(e.target.value),
})
}
/>
<Input
description="0 表示不限流量"
label="流量上限 (GB)"
type="number"
value={shareForm.maxBandwidth.toString()}
onChange={(e) =>
setShareForm({
...shareForm,
maxBandwidth: parseInt(e.target.value, 10) || 0,
})
}
/>
<Input
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
label="允许的域名 (可选)"
placeholder="example.com, panel.test.com"
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
value={shareForm.allowedDomains}
onChange={(e) => setShareForm({ ...shareForm, allowedDomains: e.target.value })}
onChange={(e) =>
setShareForm({ ...shareForm, allowedDomains: e.target.value })
}
/>
<Input
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
label="允许的API IP (可选)"
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
value={shareForm.allowedIps}
onChange={(e) => setShareForm({ ...shareForm, allowedIps: e.target.value })}
onChange={(e) =>
setShareForm({ ...shareForm, allowedIps: e.target.value })
}
/>
</ModalBody>
<ModalFooter>
<Button onPress={() => setCreateShareOpen(false)}>取消</Button>
<Button color="primary" onPress={handleCreateShare}>创建</Button>
<Button color="primary" onPress={handleCreateShare}>
创建
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Edit Share Modal */}
<Modal isOpen={editShareOpen} onClose={() => setEditShareOpen(false)}>
<ModalContent>
<ModalHeader>编辑分享</ModalHeader>
<ModalBody>
<Input
label="名称"
placeholder="备注名称"
value={editForm.name}
onChange={(e) =>
setEditForm({ ...editForm, name: e.target.value })
}
/>
<div className="flex gap-4">
<Input
label="起始端口"
type="number"
value={editForm.portRangeStart.toString()}
onChange={(e) =>
setEditForm({
...editForm,
portRangeStart: parseInt(e.target.value) || 0,
})
}
/>
<Input
label="结束端口"
type="number"
value={editForm.portRangeEnd.toString()}
onChange={(e) =>
setEditForm({
...editForm,
portRangeEnd: parseInt(e.target.value) || 0,
})
}
/>
</div>
<Input
description="0 表示不限流量"
label="流量上限 (GB)"
type="number"
value={editForm.maxBandwidth.toString()}
onChange={(e) =>
setEditForm({
...editForm,
maxBandwidth: parseInt(e.target.value, 10) || 0,
})
}
/>
<Input
description="留空或清除表示永久有效"
label="过期时间"
type="datetime-local"
value={
editForm.expiryTime > 0
? new Date(editForm.expiryTime).toISOString().slice(0, 16)
: ""
}
onChange={(e) =>
setEditForm({
...editForm,
expiryTime: e.target.value
? new Date(e.target.value).getTime()
: 0,
})
}
/>
<Input
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
label="允许的域名 (可选)"
placeholder="example.com, panel.test.com"
value={editForm.allowedDomains}
onChange={(e) =>
setEditForm({ ...editForm, allowedDomains: e.target.value })
}
/>
<Input
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
label="允许的API IP (可选)"
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
value={editForm.allowedIps}
onChange={(e) =>
setEditForm({ ...editForm, allowedIps: e.target.value })
}
/>
</ModalBody>
<ModalFooter>
<Button onPress={() => setEditShareOpen(false)}>取消</Button>
<Button color="primary" onPress={handleEditShare}>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
@@ -333,18 +804,24 @@ export default function PanelSharingPage() {
label="远程面板地址"
placeholder="http://panel.example.com:8088"
value={importForm.remoteUrl}
onChange={(e) => setImportForm({ ...importForm, remoteUrl: e.target.value })}
onChange={(e) =>
setImportForm({ ...importForm, remoteUrl: e.target.value })
}
/>
<Input
label="Token"
placeholder="Bearer Token"
value={importForm.token}
onChange={(e) => setImportForm({ ...importForm, token: e.target.value })}
onChange={(e) =>
setImportForm({ ...importForm, token: e.target.value })
}
/>
</ModalBody>
<ModalFooter>
<Button onPress={() => setImportNodeOpen(false)}>取消</Button>
<Button color="secondary" onPress={handleImportNode}>导入</Button>
<Button color="secondary" onPress={handleImportNode}>
导入
</Button>
</ModalFooter>
</ModalContent>
</Modal>
+11 -1
View File
@@ -46,6 +46,16 @@ html, body {
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
}
[data-slot="input-wrapper"] {
box-shadow: none;
}
[data-slot="input-wrapper"]:focus-within:not([data-invalid="true"]) {
outline: none;
outline-offset: 0;
box-shadow: none;
}
.safe-top {
padding-top: var(--safe-area-top);
}
@@ -85,4 +95,4 @@ html, body {
}
}
@config "../../tailwind.config.js"
@config "../../tailwind.config.js"