Compare commits

50 Commits
dev ... main

Author SHA1 Message Date
27f958b981 11111
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 33s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m44s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m58s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m23s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m55s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m11s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 2m5s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m6s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 2m1s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m8s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 3m2s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 4m27s
2026-01-12 21:22:24 +08:00
7c572ad20e 111111
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 32s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m18s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 2m20s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m0s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 2m1s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m21s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 2m30s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m48s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m4s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 2m23s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 2m7s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 2m4s
2026-01-12 20:46:55 +08:00
381e751c56 111
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 32s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m52s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m54s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m18s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m45s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m3s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m28s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m41s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m51s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m11s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m9s
2026-01-11 23:42:44 +08:00
3fe3686e29 11
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 32s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m22s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m32s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m19s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m33s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m59s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 2m35s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m23s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m48s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m58s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m16s
2026-01-11 23:27:48 +08:00
431780774f fix
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 38s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m55s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 2m12s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m42s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 2m35s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m29s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m43s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m12s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m21s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m7s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m52s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m7s
2026-01-11 23:06:16 +08:00
cb0f8df0d8 111
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 1m7s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m33s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 2m52s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m54s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 3m15s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 2m8s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 2m24s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 2m46s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 3m33s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
2026-01-11 22:51:16 +08:00
1c190330d9 1
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m15s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m19s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m14s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m39s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m4s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m28s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m33s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m55s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m3s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m35s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m9s
2026-01-11 13:04:30 +08:00
24b3b47ccd 1
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 31s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m18s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m24s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m11s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m43s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m28s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m2s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m33s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m51s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m1s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m38s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m10s
2026-01-11 11:43:07 +08:00
47603b0574 111111
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 33s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 2m45s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 5m19s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 3m42s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 2m18s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 2m13s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 5m8s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 3m26s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 2m46s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 6m28s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 4m3s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 5m37s
2026-01-09 22:22:10 +08:00
d09104f89b 111
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 38s
Build Multi-Platform Binaries / build-binaries (push) Failing after 37s
2026-01-09 22:15:47 +08:00
98f633ebde 1
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 3m16s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 5m6s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 7m56s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
2026-01-09 22:03:50 +08:00
ec4fb51aaa 1111
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 3m15s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 4m15s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 4m38s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 4m4s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 4m48s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 4m6s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 5m15s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 5m7s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 4m44s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 3m56s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 4m24s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 4m29s
2026-01-09 19:00:23 +08:00
ad07527909 111
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 3m14s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 4m3s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 4m20s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 4m2s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 4m23s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 4m29s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 4m29s
2026-01-09 18:52:39 +08:00
de206bf85a feat: add authentication options for plugins including AuthEnabled, AuthUsername, and AuthPassword
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 3m27s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 5m51s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 9m56s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 7m58s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 6m5s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 3m45s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 9m10s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 7m44s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 4m49s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 10m19s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 7m35s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 10m2s
2026-01-05 21:17:09 +08:00
c1f6e0bdcf feat: refactor logger to avoid standard output in log method and update JSPlugin HTTP handler to use goja.Callable
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 3m24s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 4m55s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 8m31s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 7m19s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 5m20s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 3m45s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 7m42s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 6m59s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 8m41s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 4m54s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 7m23s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 10m48s
2026-01-05 20:13:06 +08:00
cbd3ba0a4f 1
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 3m16s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 4m31s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 5m39s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 4m56s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 3m50s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 4m18s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 5m20s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 5m49s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 5m7s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 4m2s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 4m47s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 5m8s
2026-01-04 23:43:58 +08:00
65ad881c79 1
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
2026-01-04 23:22:10 +08:00
007c8ed440 1111
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 37s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
2026-01-04 22:48:49 +08:00
02f8c521c2 feat: update plugin handling to use unique PluginID across client and server interactions
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 56s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 7m28s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 2m44s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 7m27s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m18s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 5m49s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 2m17s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 5m4s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 5m57s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 4m36s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Failing after 13m47s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Failing after 26m24s
2026-01-04 21:31:36 +08:00
78982a26b0 feat: implement plugin API request handling with HTTP Basic Auth support
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 31s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 4m16s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 2m21s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 5m31s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
2026-01-04 20:32:21 +08:00
458bb35005 feat: add support for starting plugin rules with remote port configuration
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 28s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m7s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 50s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 2m33s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m2s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 2m26s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m32s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 2m9s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m30s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 3m6s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 2m30s
2026-01-04 19:28:00 +08:00
d6bde71c94 feat: enhance plugin configuration with remote port and schema support
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 33s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 2m35s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 55s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 3m7s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m20s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m59s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m6s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m16s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m55s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m8s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 2m57s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m56s
2026-01-03 23:53:04 +08:00
08262654d6 update
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 34s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 5m6s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m39s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 5m8s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 2m45s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 5m50s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 2m20s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 4m25s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 5m1s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m4s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 5m16s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 5m13s
2026-01-03 21:00:05 +08:00
ae6cb9d422 1
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 2m1s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 53s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 2m41s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 2m17s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m35s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m5s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m10s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m54s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 55s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 2m56s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m30s
2026-01-03 19:29:23 +08:00
f2720a3d15 feat: add .gitkeep to ensure dist directory exists for go:embed 2026-01-03 19:29:19 +08:00
2f98e1ac7d feat(logging): implement client log streaming and management
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m16s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m4s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 2m29s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 54s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 2m35s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 55s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m21s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m35s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m1s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m55s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m39s
- Added log streaming functionality for clients, allowing real-time log access via SSE.
- Introduced LogHandler to manage log streaming requests and responses.
- Implemented LogSessionManager to handle active log sessions and listeners.
- Enhanced protocol with log-related message types and structures.
- Created Logger for client-side logging, supporting various log levels and file output.
- Developed LogViewer component for the web interface to display and filter logs.
- Updated API to support log stream creation and management.
- Added support for querying logs by level and searching through log messages.
2026-01-03 16:19:52 +08:00
d2ca3fa2b9 chore: delete an unspecified file. 2026-01-03 14:09:32 +08:00
6de57a284d update md files 2026-01-03 13:36:07 +08:00
46f912423e 11
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 27s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m1s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 44s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m27s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 44s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m26s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 50s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m43s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m2s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 44s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m25s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m0s
2026-01-03 03:07:59 +08:00
5836393f1a 删除 .claude/settings.local.json 2026-01-03 02:52:12 +08:00
2aa4abb88e 1
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 28s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m3s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 46s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m26s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 44s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m27s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m43s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m42s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m3s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 44s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m25s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m1s
2026-01-03 02:51:47 +08:00
183215f410 fix
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 28s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m3s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 46s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m28s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 44s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m25s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 49s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m42s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m2s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 44s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m29s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m2s
2026-01-03 02:26:33 +08:00
73fd44ce9c 111 2026-01-03 02:07:26 +08:00
0dfd14ab5c 11111
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 29s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m3s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 49s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m30s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 46s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m29s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 51s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m44s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m5s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m15s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m35s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m3s
2026-01-03 01:55:41 +08:00
5c2727e342 1
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 35s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m9s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 54s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 2m0s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 51s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m44s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 57s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m49s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m11s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 55s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m31s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m14s
2026-01-03 00:47:04 +08:00
0389437fdb 1
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 38s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 2m41s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m15s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 3m15s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m48s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 3m35s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m9s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 3m15s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 2m58s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m28s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 3m43s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 2m53s
2026-01-02 23:53:54 +08:00
d63ff7169e update
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 38s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m12s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 54s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m32s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 48s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 2m2s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m7s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m56s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m22s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 57s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m46s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m26s
2026-01-02 14:25:25 +08:00
790c004f6e 111
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 31s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m19s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m1s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m57s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m17s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m36s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m4s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m44s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m37s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m32s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m7s
2026-01-02 13:42:27 +08:00
f4de49681d update 2026-01-02 13:10:36 +08:00
7475957195 fixed bugs
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m3s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m30s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m27s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 51s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m41s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m5s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 46s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m28s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m4s
2026-01-02 02:44:21 +08:00
f58cab4e56 111
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 29s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m6s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m28s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 46s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m26s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 52s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m42s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m3s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 46s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m30s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m5s
2026-01-02 02:08:52 +08:00
f46741a84b updete
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m4s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m29s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m27s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 50s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m42s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
2026-01-02 01:59:44 +08:00
82c1a6a266 1
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 2m28s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 2m25s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 2m24s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 3m12s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m17s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 3m23s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 2m1s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m59s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m15s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 2m21s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m50s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 2m43s
2026-01-01 22:24:19 +08:00
3f7b72a0aa 1
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 44s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 2m21s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 2m19s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 3m32s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 2m5s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 3m4s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 53s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m46s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 2m19s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m54s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m47s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 2m8s
2026-01-01 20:57:03 +08:00
0c00a9ffdc update
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 38s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 59s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m8s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m17s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 53s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m14s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 2m8s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m10s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 57s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m11s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 55s
2026-01-01 14:43:33 +08:00
Flik
76fde41e48 1
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 28s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 48s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 46s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m0s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 43s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 55s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 48s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m4s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 49s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 44s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 57s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 50s
2026-01-01 01:56:55 +08:00
Flik
cfdb890cf0 1
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 37s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m53s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m36s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m21s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m59s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m11s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m14s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m42s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m16s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m7s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m21s
2025-12-31 23:05:39 +08:00
Flik
13a94f666c Merge branch 'dev'
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Failing after 1m4s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been skipped
2025-12-31 21:29:28 +08:00
Flik
afcc54e039 Merge remote-tracking branch 'origin/main'
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 1m12s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m25s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 47s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 56s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 43s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 55s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 48s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m4s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 49s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 44s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m27s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 50s
2025-12-30 14:14:19 +08:00
913e9c304b 添加 .gitea/workflows/Create Release.yml
Some checks failed
Build Multi-Platform Binaries / build (push) Has been cancelled
2025-12-30 12:12:33 +08:00
82 changed files with 15146 additions and 3198 deletions

View File

@@ -0,0 +1,200 @@
name: Create Release
on:
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g., v1.0.0)'
required: true
type: string
release_notes:
description: 'Release notes (optional)'
required: false
type: string
prerelease:
description: 'Is this a pre-release?'
required: false
type: boolean
default: false
draft:
description: 'Create as draft?'
required: false
type: boolean
default: false
jobs:
create-release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Validate version format
run: |
VERSION="${{ inputs.version }}"
if [[ ! $VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "Error: Version must be in format vX.Y.Z or vX.Y.Z-suffix"
echo "Examples: v1.0.0, v2.1.3, v1.0.0-beta.1"
exit 1
fi
echo "Version format is valid: $VERSION"
- name: Check if tag exists
id: check_tag
run: |
if git rev-parse "${{ inputs.version }}" >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Tag ${{ inputs.version }} already exists"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Tag ${{ inputs.version }} does not exist, will create it"
fi
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: true
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Build frontend
run: |
cd web
npm ci
npm run build
mkdir -p ../internal/server/app/dist
cp -r dist/* ../internal/server/app/dist/
cd ..
echo "Frontend build completed"
ls -la internal/server/app/dist/
- name: Build all platforms
run: |
mkdir -p dist
VERSION="${{ inputs.version }}"
LDFLAGS="-s -w -X main.Version=${VERSION}"
echo "Building for all platforms with version ${VERSION}..."
# Linux amd64
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="${LDFLAGS}" \
-o dist/gotunnel-server-linux-amd64 ./cmd/server
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="${LDFLAGS}" \
-o dist/gotunnel-client-linux-amd64 ./cmd/client
# Linux arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="${LDFLAGS}" \
-o dist/gotunnel-server-linux-arm64 ./cmd/server
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="${LDFLAGS}" \
-o dist/gotunnel-client-linux-arm64 ./cmd/client
# Darwin amd64
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="${LDFLAGS}" \
-o dist/gotunnel-server-darwin-amd64 ./cmd/server
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="${LDFLAGS}" \
-o dist/gotunnel-client-darwin-amd64 ./cmd/client
# Darwin arm64
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="${LDFLAGS}" \
-o dist/gotunnel-server-darwin-arm64 ./cmd/server
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="${LDFLAGS}" \
-o dist/gotunnel-client-darwin-arm64 ./cmd/client
# Windows amd64
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="${LDFLAGS}" \
-o dist/gotunnel-server-windows-amd64.exe ./cmd/server
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="${LDFLAGS}" \
-o dist/gotunnel-client-windows-amd64.exe ./cmd/client
- name: Generate checksums
run: |
cd dist
sha256sum * > SHA256SUMS
cat SHA256SUMS
cd ..
- name: Create compressed archives
run: |
cd dist
VERSION="${{ inputs.version }}"
# Linux
tar -czf gotunnel-server-${VERSION}-linux-amd64.tar.gz gotunnel-server-linux-amd64
tar -czf gotunnel-client-${VERSION}-linux-amd64.tar.gz gotunnel-client-linux-amd64
tar -czf gotunnel-server-${VERSION}-linux-arm64.tar.gz gotunnel-server-linux-arm64
tar -czf gotunnel-client-${VERSION}-linux-arm64.tar.gz gotunnel-client-linux-arm64
# Darwin
tar -czf gotunnel-server-${VERSION}-darwin-amd64.tar.gz gotunnel-server-darwin-amd64
tar -czf gotunnel-client-${VERSION}-darwin-amd64.tar.gz gotunnel-client-darwin-amd64
tar -czf gotunnel-server-${VERSION}-darwin-arm64.tar.gz gotunnel-server-darwin-arm64
tar -czf gotunnel-client-${VERSION}-darwin-arm64.tar.gz gotunnel-client-darwin-arm64
# Windows
zip gotunnel-server-${VERSION}-windows-amd64.zip gotunnel-server-windows-amd64.exe
zip gotunnel-client-${VERSION}-windows-amd64.zip gotunnel-client-windows-amd64.exe
# Clean up raw binaries
rm gotunnel-server-* gotunnel-client-* 2>/dev/null || true
cd ..
- name: List release assets
run: |
echo "Release assets to be uploaded:"
ls -lah dist/
- name: Create tag
if: steps.check_tag.outputs.exists == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a ${{ inputs.version }} -m "Release ${{ inputs.version }}"
git push origin ${{ inputs.version }}
- name: Prepare release notes
id: release_notes
run: |
if [ -n "${{ inputs.release_notes }}" ]; then
echo "${{ inputs.release_notes }}" > release_notes.md
else
echo "Release ${{ inputs.version }}" > release_notes.md
echo "" >> release_notes.md
echo "## Assets" >> release_notes.md
echo "" >> release_notes.md
echo "Download the appropriate binary for your platform:" >> release_notes.md
echo "" >> release_notes.md
echo "- **Linux (amd64/arm64)**: \`.tar.gz\` files" >> release_notes.md
echo "- **macOS (amd64/arm64)**: \`.tar.gz\` files" >> release_notes.md
echo "- **Windows (amd64)**: \`.zip\` files" >> release_notes.md
echo "" >> release_notes.md
echo "Verify downloads with \`SHA256SUMS\`" >> release_notes.md
fi
cat release_notes.md
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ inputs.version }}
name: Release ${{ inputs.version }}
body_path: release_notes.md
files: |
dist/*.tar.gz
dist/*.zip
dist/SHA256SUMS
draft: ${{ inputs.draft }}
prerelease: ${{ inputs.prerelease }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Release created successfully
run: |
echo "✅ Release ${{ inputs.version }} created successfully!"
echo "🔗 View it at: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ inputs.version }}"

View File

@@ -9,17 +9,28 @@ on:
- '.gitea/workflows/**'
jobs:
# --- 任务 1: 构建前端 ---
build-frontend:
runs-on: node-latest
runs-on: node
steps:
- name: Checkout code
uses: actions/checkout@v4
# 直接挂载宿主机的缓存目录
- name: Build Frontend
run: |
cd web
npm ci
# 使用共享的 node_modules 缓存
if [ -d /data/cache/node_modules_cache ]; then
echo "Restoring node_modules from cache..."
cp -r /data/cache/node_modules_cache/node_modules . 2>/dev/null || true
fi
npm ci --prefer-offline --no-audit
# 保存缓存
mkdir -p /data/cache/node_modules_cache
cp -r node_modules /data/cache/node_modules_cache/ 2>/dev/null || true
npm run build
- name: Upload Frontend Artifact
@@ -29,35 +40,27 @@ jobs:
path: web/dist
retention-days: 1
# --- 任务 2: 构建多平台二进制文件 ---
build-binaries:
needs: build-frontend
runs-on: golang-latest
runs-on: golang
strategy:
fail-fast: false # 即使某个平台失败,也继续构建其他平台
fail-fast: false
matrix:
include:
# Linux 平台
- { goos: linux, goarch: amd64, target: server, upx: true }
- { goos: linux, goarch: amd64, target: client, upx: true }
- { goos: linux, goarch: arm64, target: server, upx: true } # ARMv8 64-bit
- { goos: linux, goarch: arm64, target: server, upx: true }
- { goos: linux, goarch: arm64, target: client, upx: true }
# 针对 ARMv8 (v8l) 32位模式使用 GOARM=7 确保最大兼容性
- { goos: linux, goarch: arm, goarm: 7, target: server, upx: true }
- { goos: linux, goarch: arm, goarm: 7, target: client, upx: true }
# Windows 平台
- { goos: windows, goarch: amd64, target: server, upx: true }
- { goos: windows, goarch: amd64, target: client, upx: true }
- { goos: windows, goarch: arm64, target: server, upx: false }
# Darwin (macOS) 平台
- { goos: darwin, goarch: amd64, target: server, upx: false }
- { goos: darwin, goarch: arm64, target: server, upx: false }
steps:
# 关键步骤:在 checkout 之前安装 Node.js否则 checkout@v4 会报错
- name: Install Node.js & UPX
- name: Install Dependencies
run: |
if command -v apk > /dev/null; then
apk add --no-cache nodejs upx
@@ -70,6 +73,29 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
# 使用挂载卷缓存 Go 模块
- name: Setup Go Cache
run: |
# 创建缓存目录
mkdir -p /data/cache/go-pkg-mod
mkdir -p /data/cache/go-build-cache
mkdir -p ~/go/pkg/mod
mkdir -p ~/.cache/go-build
# 恢复缓存(使用硬链接以节省空间和时间)
if [ -d /data/cache/go-pkg-mod ] && [ "$(ls -A /data/cache/go-pkg-mod)" ]; then
echo "Restoring Go pkg cache..."
cp -al /data/cache/go-pkg-mod/* ~/go/pkg/mod/ 2>/dev/null || true
fi
if [ -d /data/cache/go-build-cache ] && [ "$(ls -A /data/cache/go-build-cache)" ]; then
echo "Restoring Go build cache..."
cp -al /data/cache/go-build-cache/* ~/.cache/go-build/ 2>/dev/null || true
fi
- name: Download Go dependencies
run: go mod download
- name: Download Frontend Artifact
uses: actions/download-artifact@v3
with:
@@ -83,33 +109,37 @@ jobs:
GOARM: ${{ matrix.goarm }}
CGO_ENABLED: 0
run: |
# 处理文件名后缀
ARM_VAL=""
if [ "${{ matrix.goarch }}" = "arm" ]; then
ARM_VAL="v${{ matrix.goarm }}"
fi
[ "${{ matrix.goarch }}" = "arm" ] && ARM_VAL="v${{ matrix.goarm }}"
EXT=""
if [ "${{ matrix.goos }}" = "windows" ]; then
EXT=".exe"
fi
[ "${{ matrix.goos }}" = "windows" ] && EXT=".exe"
FILENAME="${{ matrix.target }}-${{ matrix.goos }}-${{ matrix.goarch }}${ARM_VAL}${EXT}"
FILENAME="gotunnel-${{ matrix.target }}-${{ matrix.goos }}-${{ matrix.goarch }}${ARM_VAL}${EXT}"
# 执行编译
go build -ldflags="-s -w" -o "${FILENAME}" ./cmd/${{ matrix.target }}
go build -trimpath -ldflags="-s -w" -o "${FILENAME}" ./cmd/${{ matrix.target }}
# 记录文件名供后续步骤使用
echo "CURRENT_FILENAME=${FILENAME}" >> $GITHUB_ENV
# 保存 Go 缓存(异步,不阻塞主流程)
- name: Save Go Cache
if: always()
run: |
# 只在缓存有更新时保存(使用 rsync 可以更高效)
rsync -a --delete ~/go/pkg/mod/ /data/cache/go-pkg-mod/ 2>/dev/null || \
cp -r ~/go/pkg/mod/* /data/cache/go-pkg-mod/ 2>/dev/null || true
rsync -a --delete ~/.cache/go-build/ /data/cache/go-build-cache/ 2>/dev/null || \
cp -r ~/.cache/go-build/* /data/cache/go-build-cache/ 2>/dev/null || true
- name: Run UPX Compression
if: matrix.upx == true
run: |
# 尝试压缩,即使失败也不中断工作流(某些架构不支持 UPX
upx -9 "${{ env.CURRENT_FILENAME }}" || echo "UPX skipped for this platform"
upx --best --lzma "${{ env.CURRENT_FILENAME }}" || echo "UPX skipped for this platform"
- name: Upload Binary
uses: actions/upload-artifact@v3
with:
name: ${{ env.CURRENT_FILENAME }}
path: ${{ env.CURRENT_FILENAME }}
retention-days: 7

10
.gitignore vendored
View File

@@ -26,10 +26,11 @@ Thumbs.db
# 前端 node_modules
web/node_modules/
# 构建产物 (源码在 web/dist嵌入用的在 pkg/webserver/dist)
# 构建产物 (源码在 web/dist嵌入用的在 internal/server/app/dist)
web/dist/
pkg/webserver/dist/
**/dist/**
internal/server/app/dist/*
!internal/server/app/dist/.gitkeep
build/**
# 日志
@@ -38,3 +39,8 @@ build/**
# 配置文件 (包含敏感信息)
server.yaml
client.yaml
# 临时文件
*.tmp
*.temp
.claude/*

107
CLAUDE.md
View File

@@ -20,6 +20,12 @@ go build -o client ./cmd/client
# Web UI development (in web/ directory)
cd web && npm install && npm run dev # development server
cd web && npm run build # production build (outputs to web/dist/)
# Cross-platform build (Windows PowerShell)
.\scripts\build.ps1
# Cross-platform build (Linux/Mac)
./scripts/build.sh all
```
## Architecture Overview
@@ -32,6 +38,7 @@ GoTunnel is an intranet penetration tool (similar to frp) with **server-centric
- **Binary Protocol**: `[Type(1 byte)][Length(4 bytes)][Payload(JSON)]` - see `pkg/protocol/message.go`
- **TLS by Default**: Auto-generated self-signed ECDSA P-256 certificates, no manual setup required
- **Embedded Web UI**: Vue.js SPA embedded in server binary via `//go:embed`
- **JS Plugin System**: Extensible plugin system using goja JavaScript runtime
### Package Structure
@@ -41,34 +48,36 @@ cmd/client/ # Client entry point
internal/server/
├── tunnel/ # Core tunnel server, client session management
├── config/ # YAML configuration loading
├── db/ # SQLite storage (ClientStore interface)
├── db/ # SQLite storage (ClientStore, JSPluginStore interfaces)
├── app/ # Web server, SPA handler
├── router/ # REST API endpoints
└── plugin/ # Server-side plugin manager
├── router/ # REST API endpoints (Swagger documented)
└── plugin/ # Server-side JS plugin manager
internal/client/
── tunnel/ # Client tunnel logic, auto-reconnect
└── plugin/ # Client-side plugin manager and cache
── tunnel/ # Client tunnel logic, auto-reconnect, plugin execution
pkg/
├── protocol/ # Message types and serialization
├── crypto/ # TLS certificate generation
├── proxy/ # Legacy proxy implementations
├── relay/ # Bidirectional data relay (32KB buffers)
├── auth/ # JWT authentication
├── utils/ # Port availability checking
├── version/ # Version info and update checking (Gitea API)
├── update/ # Shared update logic (download, extract tar.gz/zip)
└── plugin/ # Plugin system core
├── types.go # ProxyHandler interface, PluginMetadata
├── types.go # Plugin interfaces
├── registry.go # Plugin registry
├── builtin/ # Built-in plugins (socks5, http)
├── wasm/ # WASM runtime (wazero)
├── script/ # JS plugin runtime (goja)
├── sign/ # Plugin signature verification
└── store/ # Plugin persistence (SQLite)
web/ # Vue 3 + TypeScript frontend (Vite)
web/ # Vue 3 + TypeScript frontend (Vite + naive-ui)
scripts/ # Build scripts (build.sh, build.ps1)
```
### Key Interfaces
- `ClientStore` (internal/server/db/): Database abstraction for client rules storage
- `ServerInterface` (internal/server/router/): API handler interface
- `ProxyHandler` (pkg/plugin/): Plugin interface for proxy handlers
- `PluginStore` (pkg/plugin/store/): Plugin persistence interface
- `JSPluginStore` (internal/server/db/): JS plugin persistence
- `ServerInterface` (internal/server/router/handler/): API handler interface
- `ClientPlugin` (pkg/plugin/): Plugin interface for client-side plugins
### Proxy Types
@@ -77,9 +86,11 @@ web/ # Vue 3 + TypeScript frontend (Vite)
2. **UDP**: UDP port forwarding
3. **HTTP**: HTTP proxy through client network
4. **HTTPS**: HTTPS proxy through client network
5. **SOCKS5**: SOCKS5 proxy through client network
**插件类型** (通过 plugin 系统提供):
- **SOCKS5**: Full SOCKS5 protocol (official plugin)
**JS 插件类型** (通过 goja 运行时):
- Custom application plugins (file-server, api-server, etc.)
- Runs on client side with sandbox restrictions
### Data Flow
@@ -87,32 +98,68 @@ External User → Server Port → Yamux Stream → Client → Local Service
### Configuration
- Server: YAML config + SQLite database for client rules
- Server: YAML config + SQLite database for client rules and JS plugins
- Client: Command-line flags only (server address, token, client ID)
- Default ports: 7000 (tunnel), 7500 (web console)
## Plugin System
GoTunnel supports a WASM-based plugin system for extensible proxy handlers.
GoTunnel supports a JavaScript-based plugin system using the goja runtime.
### Plugin Architecture
- **内置类型**: tcp, udp, http, https 直接在 tunnel 代码中处理
- **Official Plugin**: SOCKS5 作为官方 plugin 提供
- **WASM Plugins**: 自定义 plugins 可通过 wazero 运行时动态加载
- **Hybrid Distribution**: 内置 plugins 离线可用WASM plugins 可从服务端下载
- **内置协议**: tcp, udp, http, https, socks5 直接在 tunnel 代码中处理
- **JS Plugins**: 自定义应用插件通过 goja 运行时在客户端执行
- **Plugin Store**: 从官方商店浏览和安装插件
- **Signature Verification**: 插件需要签名验证才能运行
### ProxyHandler Interface
### JS Plugin Lifecycle
```go
type ProxyHandler interface {
Metadata() PluginMetadata
Init(config map[string]string) error
HandleConn(conn net.Conn, dialer Dialer) error
Close() error
```javascript
function metadata() {
return {
name: "plugin-name",
version: "1.0.0",
type: "app",
description: "Plugin description",
author: "Author"
};
}
function start() { /* called on plugin start */ }
function handleConn(conn) { /* handle each connection */ }
function stop() { /* called on plugin stop */ }
```
### Creating a Built-in Plugin
### Plugin APIs
See `pkg/plugin/builtin/socks5.go` as reference implementation.
- **Basic**: `log()`, `config()`
- **Connection**: `conn.Read()`, `conn.Write()`, `conn.Close()`
- **File System**: `fs.readFile()`, `fs.writeFile()`, `fs.readDir()`, `fs.stat()`, etc.
- **HTTP**: `http.serve()`, `http.json()`, `http.sendFile()`
See `PLUGINS.md` for detailed plugin development documentation.
## API Documentation
The server provides Swagger-documented REST APIs at `/api/`.
### Key Endpoints
- `POST /api/auth/login` - JWT authentication
- `GET /api/clients` - List all clients
- `GET /api/client/{id}` - Get client details
- `PUT /api/client/{id}` - Update client config
- `POST /api/client/{id}/push` - Push config to online client
- `POST /api/client/{id}/plugin/{name}/{action}` - Plugin actions (start/stop/restart/delete)
- `GET /api/plugins` - List registered plugins
- `GET /api/update/check/server` - Check server updates
- `POST /api/update/apply/server` - Apply server update
## Update System
Both server and client support self-update from Gitea releases.
- Release assets are compressed archives (`.tar.gz` for Linux/Mac, `.zip` for Windows)
- The `pkg/update/` package handles download, extraction, and binary replacement
- Updates can be triggered from the Web UI at `/update` page

88
Makefile Normal file
View File

@@ -0,0 +1,88 @@
# GoTunnel Makefile
.PHONY: all build-frontend sync-frontend build-server build-client clean help
# 默认目标
all: build-frontend sync-frontend build-server build-client
# 构建前端
build-frontend:
@echo "Building frontend..."
cd web && npm ci && npm run build
# 同步前端到 embed 目录
sync-frontend:
@echo "Syncing frontend to embed directory..."
ifeq ($(OS),Windows_NT)
if exist internal\server\app\dist rmdir /s /q internal\server\app\dist
xcopy /E /I /Y web\dist internal\server\app\dist
else
rm -rf internal/server/app/dist
cp -r web/dist internal/server/app/dist
endif
# 仅同步(不重新构建前端)
sync-only:
@echo "Syncing existing frontend build..."
ifeq ($(OS),Windows_NT)
if exist internal\server\app\dist rmdir /s /q internal\server\app\dist
xcopy /E /I /Y web\dist internal\server\app\dist
else
rm -rf internal/server/app/dist
cp -r web/dist internal/server/app/dist
endif
# 构建服务端(当前平台)
build-server:
@echo "Building server..."
go build -ldflags="-s -w" -o gotunnel-server ./cmd/server
# 构建客户端(当前平台)
build-client:
@echo "Building client..."
go build -ldflags="-s -w" -o gotunnel-client ./cmd/client
# 构建 Linux ARM64 服务端
build-server-linux-arm64: sync-only
@echo "Building server for Linux ARM64..."
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w" -o gotunnel-server-linux-arm64 ./cmd/server
# 构建 Linux AMD64 服务端
build-server-linux-amd64: sync-only
@echo "Building server for Linux AMD64..."
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o gotunnel-server-linux-amd64 ./cmd/server
# 完整构建(包含前端)
full-build: build-frontend sync-frontend build-server build-client
# 开发模式:快速构建(假设前端已构建)
dev-build: sync-only build-server
# 清理构建产物
clean:
@echo "Cleaning..."
ifeq ($(OS),Windows_NT)
if exist gotunnel-server del gotunnel-server
if exist gotunnel-client del gotunnel-client
if exist gotunnel-server.exe del gotunnel-server.exe
if exist gotunnel-client.exe del gotunnel-client.exe
if exist gotunnel-server-* del gotunnel-server-*
if exist gotunnel-client-* del gotunnel-client-*
else
rm -f gotunnel-server gotunnel-client gotunnel-server-* gotunnel-client-*
endif
# 帮助
help:
@echo "Available targets:"
@echo " all - Build frontend, sync, and build binaries"
@echo " build-frontend - Build frontend (npm)"
@echo " sync-frontend - Sync web/dist to internal/server/app/dist"
@echo " sync-only - Sync without rebuilding frontend"
@echo " build-server - Build server for current platform"
@echo " build-client - Build client for current platform"
@echo " build-server-linux-arm64 - Cross-compile server for Linux ARM64"
@echo " build-server-linux-amd64 - Cross-compile server for Linux AMD64"
@echo " full-build - Complete build with frontend"
@echo " dev-build - Quick build (assumes frontend exists)"
@echo " clean - Remove build artifacts"

View File

@@ -1,6 +1,6 @@
# GoTunnel 插件开发指南
本文档介绍如何为 GoTunnel 开发 JS 插件。
本文档介绍如何为 GoTunnel 开发 JS 插件。JS 插件基于 [goja](https://github.com/dop251/goja) 运行时,运行在客户端上。
## 目录
@@ -66,8 +66,7 @@ function metadata() {
return {
name: "plugin-name", // 插件名称
version: "1.0.0", // 版本号
type: "app", // 类型: "app" 或 "proxy"
run_at: "client", // 运行位置: "client" 或 "server"
type: "app", // 类型: "app" (应用插件)
description: "描述", // 插件描述
author: "作者" // 作者名称
};
@@ -284,6 +283,88 @@ function handleConn(conn) {
---
### 增强 API (Enhanced APIs)
GoTunnel v2.0+ 提供了更多强大的 API 能力。
#### `logger` (日志)
推荐使用结构化日志替代简单的 `log()`
- `logger.info(msg)`
- `logger.warn(msg)`
- `logger.error(msg)`
```javascript
logger.info("Server started");
logger.error("Connection failed");
```
#### `config` (配置)
增强的配置获取方式。
- `config.get(key)`: 获取配置值
- `config.getAll()`: 获取所有配置
```javascript
var all = config.getAll();
var port = config.get("port");
```
#### `storage` (持久化存储)
简单的 Key-Value 存储,数据保存在客户端本地。
- `storage.get(key, default)`
- `storage.set(key, value)`
- `storage.delete(key)`
- `storage.keys()`
```javascript
storage.set("last_run", Date.now());
var last = storage.get("last_run", 0);
```
#### `event` (事件总线)
插件内部或插件间的事件通信。
- `event.on(name, callback)`
- `event.emit(name, data)`
- `event.off(name)`
```javascript
event.on("user_login", function(user) {
logger.info("User logged in: " + user);
});
event.emit("user_login", "admin");
```
#### `request` (HTTP 请求)
发起外部 HTTP 请求。
- `request.get(url)`
- `request.post(url, contentType, body)`
```javascript
var res = request.get("https://api.ipify.org");
logger.info("My IP: " + res.body);
```
#### `notify` (通知)
发送系统通知。
- `notify.send(title, message)`
```javascript
notify.send("Download Complete", "File saved to disk");
```
---
## 示例插件
### Echo 服务
@@ -519,16 +600,20 @@ if (result.error) {
### 配置测试
服务端配置中测试插件
Web 控制台的插件管理页面安装并配置插件,或通过 API 安装
```yaml
js_plugins:
- name: my-plugin
path: /path/to/my-plugin.js
sig_path: /path/to/my-plugin.js.sig
config:
debug: "true"
port: "8080"
```bash
# 安装 JS 插件到客户端
POST /api/client/{id}/plugin/js/install
Content-Type: application/json
{
"plugin_name": "my-plugin",
"source": "function metadata() {...}",
"rule_name": "my-rule",
"remote_port": 8080,
"config": {"debug": "true"},
"auto_start": true
}
```
---

View File

@@ -50,6 +50,7 @@ GoTunnel 是一个类似 frp 的内网穿透解决方案,核心特点是**服
- **多客户端支持** - 支持多个客户端同时连接,每个客户端独立的映射规则
- **端口冲突检测** - 自动检测系统端口占用和客户端间端口冲突
- **SOCKS5/HTTP 代理** - 支持通过客户端网络访问任意网站
- **自动更新** - 服务端和客户端支持从 Web 界面一键更新
### 安全性
@@ -192,11 +193,7 @@ web:
| `http` | HTTP 代理 | 通过客户端网络访问 HTTP/HTTPS |
| `https` | HTTPS 代理 | 同 HTTP支持 CONNECT 方法 |
### 插件类型
| 类型 | 说明 | 示例用途 |
|------|------|----------|
| `socks5` | SOCKS5 代理(官方插件) | 通过客户端网络访问任意地址 |
| `socks5` | SOCKS5 代理 | 通过客户端网络访问任意地址 |
**规则配置示例(通过 Web API**
@@ -226,9 +223,11 @@ GoTunnel/
│ │ ├── config/ # 配置管理
│ │ ├── db/ # 数据库存储
│ │ ├── app/ # Web 服务
│ │ ── router/ # API 路由
│ │ ── router/ # API 路由
│ │ └── plugin/ # 服务端插件管理
│ └── client/
── tunnel/ # 客户端隧道
── tunnel/ # 客户端隧道
│ └── plugin/ # 客户端插件管理和缓存
├── pkg/
│ ├── protocol/ # 通信协议
│ ├── crypto/ # TLS 加密
@@ -236,32 +235,31 @@ GoTunnel/
│ ├── relay/ # 数据转发
│ ├── auth/ # JWT 认证
│ ├── utils/ # 工具函数
── plugin/ # 插件系统核心
├── builtin/ # 内置插件 (socks5)
├── wasm/ # WASM 运行时 (wazero)
── version/ # 版本信息和更新检查
├── update/ # 共享更新逻辑 (下载、解压)
└── plugin/ # JS 插件系统核心 (goja)
│ └── store/ # 插件持久化 (SQLite)
├── web/ # Vue 3 + naive-ui 前端
├── scripts/ # 构建脚本
── build.sh # 跨平台构建脚本
── build.sh # Linux/macOS 构建脚本
│ └── build.ps1 # Windows 构建脚本
└── go.mod
```
## 插件系统
GoTunnel 支持灵活的插件系统,可扩展代理协议和应用功能。
GoTunnel 支持灵活的 JS 插件系统,可扩展代理协议和应用功能。
### 插件类型
| 类型 | 说明 | 运行位置 |
|------|------|----------|
| `proxy` | 代理协议插件 (如 SOCKS5) | 服务端 |
| `app` | 应用插件 (如 HTTP 文件服务) | 客户端 |
| `app` | 应用插件 (如 HTTP 文件服务、Echo 服务) | 客户端 |
### 插件来源
- **内置插件**: 编译在二进制中,离线可用
- **JS 插件**: 基于 goja 运行时,支持动态加载和热更新
- **扩展商店**: 从官方商店浏览和安装插件
- **插件商店**: 从服务端管理的插件商店浏览和安装
### 开发 JS 插件
@@ -423,10 +421,16 @@ A: 服务端会自动检测端口冲突,请检查日志并更换端口。
A: 如果客户端未指定 `-id` 参数,服务端会自动生成 16 位随机 ID。
**Q: 如何更新服务端/客户端?**
A: 在 Web 控制台的"更新"页面,可以检查并应用更新。服务端/客户端会自动从 Release 下载压缩包、解压并重启。
## 构建
使用构建脚本可以一键构建前后端:
**Linux/macOS:**
```bash
# 构建当前平台
./scripts/build.sh current
@@ -444,6 +448,25 @@ A: 如果客户端未指定 `-id` 参数,服务端会自动生成 16 位随机
VERSION=1.0.0 ./scripts/build.sh all
```
**Windows (PowerShell):**
```powershell
# 构建当前平台
.\scripts\build.ps1 current
# 构建所有平台
.\scripts\build.ps1 all
# 仅构建 Web UI
.\scripts\build.ps1 web
# 清理构建产物
.\scripts\build.ps1 clean
# 指定版本号
$env:VERSION="1.0.0"; .\scripts\build.ps1 all
```
构建产物输出到 `build/<os>_<arch>/` 目录。
## 架构时序图

Binary file not shown.

View File

@@ -1,15 +0,0 @@
server:
bind_addr: "0.0.0.0" # 监听地址
bind_port: 7001 # 监听端口
token: "flik1513." # 认证 Token不配置则自动生成
heartbeat_sec: 30 # 心跳间隔(秒)
heartbeat_timeout: 90 # 心跳超时(秒)
db_path: "gotunnel.db" # 数据库路径
tls_disabled: false # 是否禁用 TLS默认启用
web:
enabled: true # 启用 Web 控制台
bind_addr: "0.0.0.0"
bind_port: 7500
username: "admin" # 可选,设置后启用认证
password: "password"

Binary file not shown.

Binary file not shown.

View File

@@ -3,13 +3,10 @@ package main
import (
"flag"
"log"
"os"
"path/filepath"
"github.com/gotunnel/internal/client/tunnel"
"github.com/gotunnel/pkg/crypto"
"github.com/gotunnel/pkg/plugin"
"github.com/gotunnel/pkg/plugin/builtin"
)
func main() {
@@ -17,38 +14,24 @@ func main() {
token := flag.String("t", "", "auth token")
id := flag.String("id", "", "client id (optional, auto-assigned if empty)")
noTLS := flag.Bool("no-tls", false, "disable TLS")
skipVerify := flag.Bool("skip-verify", false, "skip TLS certificate verification (insecure)")
flag.Parse()
if *server == "" || *token == "" {
log.Fatal("Usage: client -s <server:port> -t <token> [-id <client_id>] [-no-tls] [-skip-verify]")
log.Fatal("Usage: client -s <server:port> -t <token> [-id <client_id>] [-no-tls]")
}
client := tunnel.NewClient(*server, *token, *id)
// TLS 默认启用,使用 TOFU 验证
// TLS 默认启用,默认跳过证书验证(类似 frp
if !*noTLS {
client.TLSEnabled = true
// 获取数据目录
home, _ := os.UserHomeDir()
dataDir := filepath.Join(home, ".gotunnel")
client.TLSConfig = crypto.ClientTLSConfigWithTOFU(*server, dataDir, *skipVerify)
if *skipVerify {
log.Printf("[Client] TLS enabled (certificate verification DISABLED - insecure)")
} else {
log.Printf("[Client] TLS enabled with TOFU certificate verification")
}
client.TLSConfig = crypto.ClientTLSConfig()
log.Printf("[Client] TLS enabled")
}
// 初始化插件系统
// 初始化插件注册表(用于 JS 插件)
registry := plugin.NewRegistry()
for _, h := range builtin.GetClientPlugins() {
if err := registry.RegisterClient(h); err != nil {
log.Fatalf("[Plugin] Register error: %v", err)
}
}
client.SetPluginRegistry(registry)
log.Printf("[Plugin] Registered %d plugins", len(builtin.GetClientPlugins()))
client.Run()
}

View File

@@ -1,5 +1,15 @@
package main
// @title GoTunnel API
// @version 1.0
// @description GoTunnel 内网穿透服务器 API
// @host localhost:7500
// @BasePath /
// @securityDefinitions.apikey Bearer
// @in header
// @name Authorization
// @description JWT Bearer token
import (
"flag"
"fmt"
@@ -9,13 +19,14 @@ import (
"syscall"
"time"
_ "github.com/gotunnel/docs" // Swagger docs
"github.com/gotunnel/internal/server/app"
"github.com/gotunnel/internal/server/config"
"github.com/gotunnel/internal/server/db"
"github.com/gotunnel/internal/server/tunnel"
"github.com/gotunnel/pkg/crypto"
"github.com/gotunnel/pkg/plugin"
"github.com/gotunnel/pkg/plugin/builtin"
"github.com/gotunnel/pkg/plugin/sign"
)
@@ -59,13 +70,10 @@ func main() {
log.Printf("[Server] TLS enabled")
}
// 初始化插件系统
// 初始化插件系统(用于客户端 JS 插件管理)
registry := plugin.NewRegistry()
if err := registry.RegisterAllServer(builtin.GetServerPlugins()); err != nil {
log.Fatalf("[Plugin] Register error: %v", err)
}
server.SetPluginRegistry(registry)
log.Printf("[Plugin] Registered %d plugins", len(builtin.GetServerPlugins()))
server.SetJSPluginStore(clientStore) // 设置 JS 插件存储,用于客户端重连时恢复插件
// 加载 JS 插件配置
if len(cfg.JSPlugins) > 0 {

2398
docs/docs.go Normal file

File diff suppressed because it is too large Load Diff

2374
docs/swagger.json Normal file

File diff suppressed because it is too large Load Diff

1500
docs/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

62
go.mod
View File

@@ -10,19 +10,77 @@ require (
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.2.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dop251/goja v0.0.0-20251201205617-2bb4c724c0f9 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.11.0 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/spec v0.22.3 // indirect
github.com/go-openapi/swag v0.25.4 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.1 // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.58.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/swaggo/gin-swagger v1.6.1 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/urfave/cli/v2 v2.27.7 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)

195
go.sum
View File

@@ -1,40 +1,233 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9N28=
github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20251201205617-2bb4c724c0f9 h1:3uSSOd6mVlwcX3k5OYOpiDqFgRmaE2dBfLvVIFWWHrw=
github.com/dop251/goja v0.0.0-20251201205617-2bb4c724c0f9/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
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/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
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/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
@@ -63,3 +256,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=

View File

@@ -1,51 +0,0 @@
package plugin
import (
"log"
"sync"
"github.com/gotunnel/pkg/plugin"
"github.com/gotunnel/pkg/plugin/builtin"
)
// Manager 客户端 plugin 管理器
type Manager struct {
registry *plugin.Registry
mu sync.RWMutex
}
// NewManager 创建客户端 plugin 管理器
func NewManager() (*Manager, error) {
registry := plugin.NewRegistry()
m := &Manager{
registry: registry,
}
if err := m.registerBuiltins(); err != nil {
return nil, err
}
return m, nil
}
// registerBuiltins 注册内置 plugins
func (m *Manager) registerBuiltins() error {
for _, h := range builtin.GetClientPlugins() {
if err := m.registry.RegisterClient(h); err != nil {
return err
}
}
log.Printf("[Plugin] Registered %d client plugins", len(builtin.GetClientPlugins()))
return nil
}
// GetClient 返回客户端插件
func (m *Manager) GetClient(name string) (plugin.ClientPlugin, error) {
return m.registry.GetClient(name)
}
// GetRegistry 返回插件注册表
func (m *Manager) GetRegistry() *plugin.Registry {
return m.registry
}

View File

@@ -6,7 +6,10 @@ import (
"log"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
@@ -15,6 +18,7 @@ import (
"github.com/gotunnel/pkg/plugin/sign"
"github.com/gotunnel/pkg/protocol"
"github.com/gotunnel/pkg/relay"
"github.com/gotunnel/pkg/update"
"github.com/hashicorp/yamux"
)
@@ -44,6 +48,7 @@ type Client struct {
runningPlugins map[string]plugin.ClientPlugin
versionStore *PluginVersionStore
pluginMu sync.RWMutex
logger *Logger // 日志收集器
}
// NewClient 创建客户端
@@ -56,12 +61,19 @@ func NewClient(serverAddr, token, id string) *Client {
home, _ := os.UserHomeDir()
dataDir := filepath.Join(home, ".gotunnel")
// 初始化日志收集器
logger, err := NewLogger(dataDir)
if err != nil {
log.Printf("[Client] Failed to initialize logger: %v", err)
}
return &Client{
ServerAddr: serverAddr,
Token: token,
ID: id,
DataDir: dataDir,
runningPlugins: make(map[string]plugin.ClientPlugin),
logger: logger,
}
}
@@ -105,18 +117,45 @@ func (c *Client) SetPluginRegistry(registry *plugin.Registry) {
c.pluginRegistry = registry
}
// logf 安全地记录日志(同时输出到标准日志和日志收集器)
func (c *Client) logf(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
log.Print(msg)
if c.logger != nil {
c.logger.Printf(msg)
}
}
// logErrorf 安全地记录错误日志
func (c *Client) logErrorf(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
log.Print(msg)
if c.logger != nil {
c.logger.Errorf(msg)
}
}
// logWarnf 安全地记录警告日志
func (c *Client) logWarnf(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
log.Print(msg)
if c.logger != nil {
c.logger.Warnf(msg)
}
}
// Run 启动客户端(带断线重连)
func (c *Client) Run() error {
for {
if err := c.connect(); err != nil {
log.Printf("[Client] Connect error: %v", err)
log.Printf("[Client] Reconnecting in %v...", reconnectDelay)
c.logErrorf("[Client] Connect error: %v", err)
c.logf("[Client] Reconnecting in %v...", reconnectDelay)
time.Sleep(reconnectDelay)
continue
}
c.handleSession()
log.Printf("[Client] Disconnected, reconnecting...")
c.logWarnf("[Client] Disconnected, reconnecting...")
time.Sleep(disconnectDelay)
}
}
@@ -163,10 +202,10 @@ func (c *Client) connect() error {
if authResp.ClientID != "" && authResp.ClientID != c.ID {
c.ID = authResp.ClientID
saveClientID(c.ID)
log.Printf("[Client] New ID assigned and saved: %s", c.ID)
c.logf("[Client] New ID assigned and saved: %s", c.ID)
}
log.Printf("[Client] Authenticated as %s", c.ID)
c.logf("[Client] Authenticated as %s", c.ID)
session, err := yamux.Client(conn, nil)
if err != nil {
@@ -221,10 +260,26 @@ func (c *Client) handleStream(stream net.Conn) {
c.handlePluginConfig(msg)
case protocol.MsgTypeClientPluginStart:
c.handleClientPluginStart(stream, msg)
case protocol.MsgTypeClientPluginStop:
c.handleClientPluginStop(stream, msg)
case protocol.MsgTypeClientPluginConn:
c.handleClientPluginConn(stream, msg)
case protocol.MsgTypeJSPluginInstall:
c.handleJSPluginInstall(stream, msg)
case protocol.MsgTypeClientRestart:
c.handleClientRestart(stream, msg)
case protocol.MsgTypePluginConfigUpdate:
c.handlePluginConfigUpdate(stream, msg)
case protocol.MsgTypeUpdateDownload:
c.handleUpdateDownload(stream, msg)
case protocol.MsgTypeLogRequest:
go c.handleLogRequest(stream, msg)
case protocol.MsgTypeLogStop:
c.handleLogStop(stream, msg)
case protocol.MsgTypePluginStatusQuery:
c.handlePluginStatusQuery(stream, msg)
case protocol.MsgTypePluginAPIRequest:
c.handlePluginAPIRequest(stream, msg)
}
}
@@ -232,7 +287,7 @@ func (c *Client) handleStream(stream net.Conn) {
func (c *Client) handleProxyConfig(msg *protocol.Message) {
var cfg protocol.ProxyConfig
if err := msg.ParsePayload(&cfg); err != nil {
log.Printf("[Client] Parse proxy config error: %v", err)
c.logErrorf("[Client] Parse proxy config error: %v", err)
return
}
@@ -240,9 +295,9 @@ func (c *Client) handleProxyConfig(msg *protocol.Message) {
c.rules = cfg.Rules
c.mu.Unlock()
log.Printf("[Client] Received %d proxy rules", len(cfg.Rules))
c.logf("[Client] Received %d proxy rules", len(cfg.Rules))
for _, r := range cfg.Rules {
log.Printf("[Client] %s: %s:%d", r.Name, r.LocalIP, r.LocalPort)
c.logf("[Client] %s: %s:%d", r.Name, r.LocalIP, r.LocalPort)
}
}
@@ -250,7 +305,7 @@ func (c *Client) handleProxyConfig(msg *protocol.Message) {
func (c *Client) handleNewProxy(stream net.Conn, msg *protocol.Message) {
var req protocol.NewProxyRequest
if err := msg.ParsePayload(&req); err != nil {
log.Printf("[Client] Parse new proxy request error: %v", err)
c.logErrorf("[Client] Parse new proxy request error: %v", err)
return
}
@@ -265,14 +320,14 @@ func (c *Client) handleNewProxy(stream net.Conn, msg *protocol.Message) {
c.mu.RUnlock()
if rule == nil {
log.Printf("[Client] Unknown port %d", req.RemotePort)
c.logWarnf("[Client] Unknown port %d", req.RemotePort)
return
}
localAddr := fmt.Sprintf("%s:%d", rule.LocalIP, rule.LocalPort)
localConn, err := net.DialTimeout("tcp", localAddr, localDialTimeout)
if err != nil {
log.Printf("[Client] Connect %s error: %v", localAddr, err)
c.logErrorf("[Client] Connect %s error: %v", localAddr, err)
return
}
@@ -382,24 +437,24 @@ func (c *Client) findRuleByPort(port int) *protocol.ProxyRule {
func (c *Client) handlePluginConfig(msg *protocol.Message) {
var cfg protocol.PluginConfigSync
if err := msg.ParsePayload(&cfg); err != nil {
log.Printf("[Client] Parse plugin config error: %v", err)
c.logErrorf("[Client] Parse plugin config error: %v", err)
return
}
log.Printf("[Client] Received config for plugin: %s", cfg.PluginName)
c.logf("[Client] Received config for plugin: %s", cfg.PluginName)
// 应用配置到插件
if c.pluginRegistry != nil {
handler, err := c.pluginRegistry.GetClient(cfg.PluginName)
if err != nil {
log.Printf("[Client] Plugin %s not found: %v", cfg.PluginName, err)
c.logWarnf("[Client] Plugin %s not found: %v", cfg.PluginName, err)
return
}
if err := handler.Init(cfg.Config); err != nil {
log.Printf("[Client] Plugin %s init error: %v", cfg.PluginName, err)
c.logErrorf("[Client] Plugin %s init error: %v", cfg.PluginName, err)
return
}
log.Printf("[Client] Plugin %s config applied", cfg.PluginName)
c.logf("[Client] Plugin %s config applied", cfg.PluginName)
}
}
@@ -413,7 +468,7 @@ func (c *Client) handleClientPluginStart(stream net.Conn, msg *protocol.Message)
return
}
log.Printf("[Client] Starting plugin %s for rule %s", req.PluginName, req.RuleName)
c.logf("[Client] Starting plugin %s for rule %s", req.PluginName, req.RuleName)
// 获取插件
if c.pluginRegistry == nil {
@@ -445,7 +500,7 @@ func (c *Client) handleClientPluginStart(stream net.Conn, msg *protocol.Message)
c.runningPlugins[key] = handler
c.pluginMu.Unlock()
log.Printf("[Client] Plugin %s started at %s", req.PluginName, localAddr)
c.logf("[Client] Plugin %s started at %s", req.PluginName, localAddr)
c.sendPluginStatus(stream, req.PluginName, req.RuleName, true, localAddr, "")
}
@@ -470,13 +525,24 @@ func (c *Client) handleClientPluginConn(stream net.Conn, msg *protocol.Message)
return
}
key := req.PluginName + ":" + req.RuleName
c.pluginMu.RLock()
handler, ok := c.runningPlugins[key]
var handler plugin.ClientPlugin
var ok bool
// 优先使用 PluginID 查找
if req.PluginID != "" {
handler, ok = c.runningPlugins[req.PluginID]
}
// 如果没找到,回退到 pluginName:ruleName
if !ok {
key := req.PluginName + ":" + req.RuleName
handler, ok = c.runningPlugins[key]
}
c.pluginMu.RUnlock()
if !ok {
log.Printf("[Client] Plugin %s not running", key)
c.logWarnf("[Client] Plugin %s (ID: %s) not running", req.PluginName, req.PluginID)
stream.Close()
return
}
@@ -495,15 +561,32 @@ func (c *Client) handleJSPluginInstall(stream net.Conn, msg *protocol.Message) {
return
}
log.Printf("[Client] Installing JS plugin: %s", req.PluginName)
c.logf("[Client] Installing JS plugin: %s (ID: %s)", req.PluginName, req.PluginID)
// 使用 PluginID 作为 key如果有否则回退到 pluginName:ruleName
key := req.PluginID
if key == "" {
key = req.PluginName + ":" + req.RuleName
}
// 如果插件已经在运行,先停止它
c.pluginMu.Lock()
if existingHandler, ok := c.runningPlugins[key]; ok {
c.logf("[Client] Stopping existing plugin %s before reinstall", key)
if err := existingHandler.Stop(); err != nil {
c.logErrorf("[Client] Stop existing plugin error: %v", err)
}
delete(c.runningPlugins, key)
}
c.pluginMu.Unlock()
// 验证官方签名
if err := c.verifyJSPluginSignature(req.PluginName, req.Source, req.Signature); err != nil {
log.Printf("[Client] JS plugin %s signature verification failed: %v", req.PluginName, err)
c.logErrorf("[Client] JS plugin %s signature verification failed: %v", req.PluginName, err)
c.sendJSPluginResult(stream, req.PluginName, false, "signature verification failed: "+err.Error())
return
}
log.Printf("[Client] JS plugin %s signature verified", req.PluginName)
c.logf("[Client] JS plugin %s signature verified", req.PluginName)
// 创建 JS 插件
jsPlugin, err := script.NewJSPlugin(req.PluginName, req.Source)
@@ -517,8 +600,7 @@ func (c *Client) handleJSPluginInstall(stream net.Conn, msg *protocol.Message) {
c.pluginRegistry.RegisterClient(jsPlugin)
}
log.Printf("[Client] JS plugin %s installed", req.PluginName)
c.sendJSPluginResult(stream, req.PluginName, true, "")
c.logf("[Client] JS plugin %s installed", req.PluginName)
// 保存版本信息(防止降级攻击)
if c.versionStore != nil {
@@ -528,10 +610,13 @@ func (c *Client) handleJSPluginInstall(stream net.Conn, msg *protocol.Message) {
}
}
// 自动启动
// 先启动插件,再发送安装结果
// 这样服务端收到结果后启动监听器时,客户端插件已经准备好了
if req.AutoStart {
c.startJSPlugin(jsPlugin, req)
}
c.sendJSPluginResult(stream, req.PluginName, true, "")
}
// sendJSPluginResult 发送 JS 插件安装结果
@@ -548,22 +633,26 @@ func (c *Client) sendJSPluginResult(stream net.Conn, name string, success bool,
// startJSPlugin 启动 JS 插件
func (c *Client) startJSPlugin(handler plugin.ClientPlugin, req protocol.JSPluginInstallRequest) {
if err := handler.Init(req.Config); err != nil {
log.Printf("[Client] JS plugin %s init error: %v", req.PluginName, err)
c.logErrorf("[Client] JS plugin %s init error: %v", req.PluginName, err)
return
}
localAddr, err := handler.Start()
if err != nil {
log.Printf("[Client] JS plugin %s start error: %v", req.PluginName, err)
c.logErrorf("[Client] JS plugin %s start error: %v", req.PluginName, err)
return
}
key := req.PluginName + ":" + req.RuleName
// 使用 PluginID 作为 key如果有否则回退到 pluginName:ruleName
key := req.PluginID
if key == "" {
key = req.PluginName + ":" + req.RuleName
}
c.pluginMu.Lock()
c.runningPlugins[key] = handler
c.pluginMu.Unlock()
log.Printf("[Client] JS plugin %s started at %s", req.PluginName, localAddr)
c.logf("[Client] JS plugin %s (ID: %s) started at %s", req.PluginName, req.PluginID, localAddr)
}
// verifyJSPluginSignature 验证 JS 插件签名
@@ -609,3 +698,472 @@ func (c *Client) verifyJSPluginSignature(pluginName, source, signature string) e
return nil
}
// handleClientPluginStop 处理客户端插件停止请求
func (c *Client) handleClientPluginStop(stream net.Conn, msg *protocol.Message) {
defer stream.Close()
var req protocol.ClientPluginStopRequest
if err := msg.ParsePayload(&req); err != nil {
c.sendPluginStatus(stream, req.PluginName, req.RuleName, true, "", err.Error())
return
}
c.pluginMu.Lock()
var handler plugin.ClientPlugin
var key string
var ok bool
// 优先使用 PluginID 查找
if req.PluginID != "" {
handler, ok = c.runningPlugins[req.PluginID]
if ok {
key = req.PluginID
}
}
// 如果没找到,回退到 pluginName:ruleName
if !ok {
key = req.PluginName + ":" + req.RuleName
handler, ok = c.runningPlugins[key]
}
if ok {
if err := handler.Stop(); err != nil {
c.logErrorf("[Client] Plugin %s stop error: %v", key, err)
}
delete(c.runningPlugins, key)
}
c.pluginMu.Unlock()
c.logf("[Client] Plugin %s stopped", key)
c.sendPluginStatus(stream, req.PluginName, req.RuleName, false, "", "")
}
// handleClientRestart 处理客户端重启请求
func (c *Client) handleClientRestart(stream net.Conn, msg *protocol.Message) {
defer stream.Close()
var req protocol.ClientRestartRequest
msg.ParsePayload(&req)
c.logf("[Client] Restart requested: %s", req.Reason)
// 发送响应
resp := protocol.ClientRestartResponse{
Success: true,
Message: "restarting",
}
respMsg, _ := protocol.NewMessage(protocol.MsgTypeClientRestart, resp)
protocol.WriteMessage(stream, respMsg)
// 停止所有运行中的插件
c.pluginMu.Lock()
for key, handler := range c.runningPlugins {
c.logf("[Client] Stopping plugin %s for restart", key)
handler.Stop()
}
c.runningPlugins = make(map[string]plugin.ClientPlugin)
c.pluginMu.Unlock()
// 关闭会话(会触发重连)
if c.session != nil {
c.session.Close()
}
}
// handlePluginConfigUpdate 处理插件配置更新请求
func (c *Client) handlePluginConfigUpdate(stream net.Conn, msg *protocol.Message) {
defer stream.Close()
var req protocol.PluginConfigUpdateRequest
if err := msg.ParsePayload(&req); err != nil {
c.sendPluginConfigUpdateResult(stream, req.PluginName, req.RuleName, false, err.Error())
return
}
c.pluginMu.RLock()
var handler plugin.ClientPlugin
var key string
var ok bool
// 优先使用 PluginID 查找
if req.PluginID != "" {
handler, ok = c.runningPlugins[req.PluginID]
if ok {
key = req.PluginID
}
}
// 如果没找到,回退到 pluginName:ruleName
if !ok {
key = req.PluginName + ":" + req.RuleName
handler, ok = c.runningPlugins[key]
}
c.pluginMu.RUnlock()
c.logf("[Client] Config update for plugin %s", key)
if !ok {
c.sendPluginConfigUpdateResult(stream, req.PluginName, req.RuleName, false, "plugin not running")
return
}
if req.Restart {
// 停止并重启插件
c.pluginMu.Lock()
if err := handler.Stop(); err != nil {
c.logErrorf("[Client] Plugin %s stop error: %v", key, err)
}
delete(c.runningPlugins, key)
c.pluginMu.Unlock()
// 重新初始化和启动
if err := handler.Init(req.Config); err != nil {
c.sendPluginConfigUpdateResult(stream, req.PluginName, req.RuleName, false, err.Error())
return
}
localAddr, err := handler.Start()
if err != nil {
c.sendPluginConfigUpdateResult(stream, req.PluginName, req.RuleName, false, err.Error())
return
}
c.pluginMu.Lock()
c.runningPlugins[key] = handler
c.pluginMu.Unlock()
c.logf("[Client] Plugin %s restarted at %s with new config", key, localAddr)
}
c.sendPluginConfigUpdateResult(stream, req.PluginName, req.RuleName, true, "")
}
// sendPluginConfigUpdateResult 发送插件配置更新结果
func (c *Client) sendPluginConfigUpdateResult(stream net.Conn, pluginName, ruleName string, success bool, errMsg string) {
result := protocol.PluginConfigUpdateResponse{
PluginName: pluginName,
RuleName: ruleName,
Success: success,
Error: errMsg,
}
msg, _ := protocol.NewMessage(protocol.MsgTypePluginConfigUpdate, result)
protocol.WriteMessage(stream, msg)
}
// handleUpdateDownload 处理更新下载请求
func (c *Client) handleUpdateDownload(stream net.Conn, msg *protocol.Message) {
defer stream.Close()
var req protocol.UpdateDownloadRequest
if err := msg.ParsePayload(&req); err != nil {
c.logErrorf("[Client] Parse update request error: %v", err)
c.sendUpdateResult(stream, false, "invalid request")
return
}
c.logf("[Client] Update download requested: %s", req.DownloadURL)
// 异步执行更新
go func() {
if err := c.performSelfUpdate(req.DownloadURL); err != nil {
c.logErrorf("[Client] Update failed: %v", err)
}
}()
c.sendUpdateResult(stream, true, "update started")
}
// sendUpdateResult 发送更新结果
func (c *Client) sendUpdateResult(stream net.Conn, success bool, message string) {
result := protocol.UpdateResultResponse{
Success: success,
Message: message,
}
msg, _ := protocol.NewMessage(protocol.MsgTypeUpdateResult, result)
protocol.WriteMessage(stream, msg)
}
// performSelfUpdate 执行自更新
func (c *Client) performSelfUpdate(downloadURL string) error {
c.logf("[Client] Starting self-update from: %s", downloadURL)
// 使用共享的下载和解压逻辑
binaryPath, cleanup, err := update.DownloadAndExtract(downloadURL, "client")
if err != nil {
return err
}
defer cleanup()
// 获取当前可执行文件路径
currentPath, err := os.Executable()
if err != nil {
return fmt.Errorf("get executable: %w", err)
}
currentPath, _ = filepath.EvalSymlinks(currentPath)
// Windows 需要特殊处理
if runtime.GOOS == "windows" {
return performWindowsClientUpdate(binaryPath, currentPath, c.ServerAddr, c.Token, c.ID)
}
// Linux/Mac: 直接替换
backupPath := currentPath + ".bak"
// 停止所有插件
c.stopAllPlugins()
// 备份当前文件
if err := os.Rename(currentPath, backupPath); err != nil {
return fmt.Errorf("backup current: %w", err)
}
// 复制新文件(不能用 rename可能跨文件系统
if err := update.CopyFile(binaryPath, currentPath); err != nil {
os.Rename(backupPath, currentPath)
return fmt.Errorf("replace binary: %w", err)
}
// 设置执行权限
if err := os.Chmod(currentPath, 0755); err != nil {
os.Rename(backupPath, currentPath)
return fmt.Errorf("chmod: %w", err)
}
// 删除备份
os.Remove(backupPath)
c.logf("[Client] Update completed, restarting...")
// 重启进程
restartClientProcess(currentPath, c.ServerAddr, c.Token, c.ID)
return nil
}
// stopAllPlugins 停止所有运行中的插件
func (c *Client) stopAllPlugins() {
c.pluginMu.Lock()
for key, handler := range c.runningPlugins {
c.logf("[Client] Stopping plugin %s for update", key)
handler.Stop()
}
c.runningPlugins = make(map[string]plugin.ClientPlugin)
c.pluginMu.Unlock()
}
// performWindowsClientUpdate Windows 平台更新
func performWindowsClientUpdate(newFile, currentPath, serverAddr, token, id string) error {
// 创建批处理脚本
args := fmt.Sprintf(`-s "%s" -t "%s"`, serverAddr, token)
if id != "" {
args += fmt.Sprintf(` -id "%s"`, id)
}
batchScript := fmt.Sprintf(`@echo off
ping 127.0.0.1 -n 2 > nul
del "%s"
move "%s" "%s"
start "" "%s" %s
del "%%~f0"
`, currentPath, newFile, currentPath, currentPath, args)
batchPath := filepath.Join(os.TempDir(), "gotunnel_client_update.bat")
if err := os.WriteFile(batchPath, []byte(batchScript), 0755); err != nil {
return fmt.Errorf("write batch: %w", err)
}
cmd := exec.Command("cmd", "/C", "start", "/MIN", batchPath)
if err := cmd.Start(); err != nil {
return fmt.Errorf("start batch: %w", err)
}
// 退出当前进程
os.Exit(0)
return nil
}
// restartClientProcess 重启客户端进程
func restartClientProcess(path, serverAddr, token, id string) {
args := []string{"-s", serverAddr, "-t", token}
if id != "" {
args = append(args, "-id", id)
}
cmd := exec.Command(path, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Start()
os.Exit(0)
}
// handlePluginStatusQuery 处理插件状态查询
func (c *Client) handlePluginStatusQuery(stream net.Conn, msg *protocol.Message) {
defer stream.Close()
c.pluginMu.RLock()
plugins := make([]protocol.PluginStatusEntry, 0, len(c.runningPlugins))
for key, handler := range c.runningPlugins {
// 从插件的 Metadata 获取真正的插件名称
pluginName := handler.Metadata().Name
// 如果 Metadata 没有名称,回退到从 key 解析
if pluginName == "" {
parts := strings.SplitN(key, ":", 2)
pluginName = parts[0]
}
plugins = append(plugins, protocol.PluginStatusEntry{
PluginName: pluginName,
Running: true,
})
}
c.pluginMu.RUnlock()
resp := protocol.PluginStatusQueryResponse{
Plugins: plugins,
}
respMsg, _ := protocol.NewMessage(protocol.MsgTypePluginStatusQueryResp, resp)
protocol.WriteMessage(stream, respMsg)
}
// handleLogRequest 处理日志请求
func (c *Client) handleLogRequest(stream net.Conn, msg *protocol.Message) {
if c.logger == nil {
stream.Close()
return
}
var req protocol.LogRequest
if err := msg.ParsePayload(&req); err != nil {
stream.Close()
return
}
c.logger.Printf("Log request received: session=%s, follow=%v", req.SessionID, req.Follow)
// 发送历史日志
entries := c.logger.GetRecentLogs(req.Lines, req.Level)
if len(entries) > 0 {
data := protocol.LogData{
SessionID: req.SessionID,
Entries: entries,
EOF: !req.Follow,
}
respMsg, _ := protocol.NewMessage(protocol.MsgTypeLogData, data)
if err := protocol.WriteMessage(stream, respMsg); err != nil {
stream.Close()
return
}
}
// 如果不需要持续推送,关闭流
if !req.Follow {
stream.Close()
return
}
// 订阅新日志
ch := c.logger.Subscribe(req.SessionID)
defer c.logger.Unsubscribe(req.SessionID)
defer stream.Close()
// 持续推送新日志
for entry := range ch {
// 应用级别过滤
if req.Level != "" && entry.Level != req.Level {
continue
}
data := protocol.LogData{
SessionID: req.SessionID,
Entries: []protocol.LogEntry{entry},
EOF: false,
}
respMsg, _ := protocol.NewMessage(protocol.MsgTypeLogData, data)
if err := protocol.WriteMessage(stream, respMsg); err != nil {
return
}
}
}
// handleLogStop 处理停止日志流请求
func (c *Client) handleLogStop(stream net.Conn, msg *protocol.Message) {
defer stream.Close()
if c.logger == nil {
return
}
var req protocol.LogStopRequest
if err := msg.ParsePayload(&req); err != nil {
return
}
c.logger.Unsubscribe(req.SessionID)
}
// handlePluginAPIRequest 处理插件 API 请求
func (c *Client) handlePluginAPIRequest(stream net.Conn, msg *protocol.Message) {
defer stream.Close()
var req protocol.PluginAPIRequest
if err := msg.ParsePayload(&req); err != nil {
c.sendPluginAPIResponse(stream, 400, nil, "", "invalid request: "+err.Error())
return
}
c.logf("[Client] Plugin API request: %s %s for plugin %s (ID: %s)", req.Method, req.Path, req.PluginName, req.PluginID)
// 查找运行中的插件
c.pluginMu.RLock()
var handler plugin.ClientPlugin
// 优先使用 PluginID 查找
if req.PluginID != "" {
handler = c.runningPlugins[req.PluginID]
}
// 如果没找到,尝试通过 PluginName 匹配(向后兼容)
if handler == nil && req.PluginName != "" {
for key, p := range c.runningPlugins {
// key 可能是 PluginID 或 "pluginName:ruleName" 格式
if strings.HasPrefix(key, req.PluginName+":") {
handler = p
break
}
}
}
c.pluginMu.RUnlock()
if handler == nil {
c.sendPluginAPIResponse(stream, 404, nil, "", "plugin not running: "+req.PluginName)
return
}
// 类型断言为 JSPlugin
jsPlugin, ok := handler.(*script.JSPlugin)
if !ok {
c.sendPluginAPIResponse(stream, 500, nil, "", "plugin does not support API routing")
return
}
// 调用插件的 API 处理函数
status, headers, body, err := jsPlugin.HandleAPIRequest(req.Method, req.Path, req.Query, req.Headers, req.Body)
if err != nil {
c.sendPluginAPIResponse(stream, 500, nil, "", err.Error())
return
}
c.sendPluginAPIResponse(stream, status, headers, body, "")
}
// sendPluginAPIResponse 发送插件 API 响应
func (c *Client) sendPluginAPIResponse(stream net.Conn, status int, headers map[string]string, body, errMsg string) {
resp := protocol.PluginAPIResponse{
Status: status,
Headers: headers,
Body: body,
Error: errMsg,
}
msg, _ := protocol.NewMessage(protocol.MsgTypePluginAPIResponse, resp)
protocol.WriteMessage(stream, msg)
}

View File

@@ -0,0 +1,236 @@
package tunnel
import (
"container/ring"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/gotunnel/pkg/protocol"
)
const (
maxBufferSize = 1000 // 环形缓冲区最大条目数
logFilePattern = "client.%s.log" // 日志文件名模式
)
// LogLevel 日志级别
type LogLevel int
const (
LevelDebug LogLevel = iota
LevelInfo
LevelWarn
LevelError
)
// Logger 客户端日志收集器
type Logger struct {
dataDir string
buffer *ring.Ring
bufferMu sync.RWMutex
file *os.File
fileMu sync.Mutex
fileDate string
subscribers map[string]chan protocol.LogEntry
subMu sync.RWMutex
}
// NewLogger 创建新的日志收集器
func NewLogger(dataDir string) (*Logger, error) {
l := &Logger{
dataDir: dataDir,
buffer: ring.New(maxBufferSize),
subscribers: make(map[string]chan protocol.LogEntry),
}
// 确保日志目录存在
logDir := filepath.Join(dataDir, "logs")
if err := os.MkdirAll(logDir, 0755); err != nil {
return nil, err
}
return l, nil
}
// Printf 记录日志 (兼容 log.Printf)
func (l *Logger) Printf(format string, args ...interface{}) {
l.log(LevelInfo, "client", format, args...)
}
// Infof 记录信息日志
func (l *Logger) Infof(format string, args ...interface{}) {
l.log(LevelInfo, "client", format, args...)
}
// Warnf 记录警告日志
func (l *Logger) Warnf(format string, args ...interface{}) {
l.log(LevelWarn, "client", format, args...)
}
// Errorf 记录错误日志
func (l *Logger) Errorf(format string, args ...interface{}) {
l.log(LevelError, "client", format, args...)
}
// Debugf 记录调试日志
func (l *Logger) Debugf(format string, args ...interface{}) {
l.log(LevelDebug, "client", format, args...)
}
// PluginLog 记录插件日志
func (l *Logger) PluginLog(pluginName, level, format string, args ...interface{}) {
var lvl LogLevel
switch level {
case "debug":
lvl = LevelDebug
case "warn":
lvl = LevelWarn
case "error":
lvl = LevelError
default:
lvl = LevelInfo
}
l.log(lvl, "plugin:"+pluginName, format, args...)
}
func (l *Logger) log(level LogLevel, source, format string, args ...interface{}) {
entry := protocol.LogEntry{
Timestamp: time.Now().UnixMilli(),
Level: levelToString(level),
Message: fmt.Sprintf(format, args...),
Source: source,
}
// 注意不在这里输出到标准输出因为调用方logf/logErrorf/logWarnf已经调用了 log.Print
// 这里只负责:缓冲区存储、文件写入、订阅者通知
// 添加到环形缓冲区
l.bufferMu.Lock()
l.buffer.Value = entry
l.buffer = l.buffer.Next()
l.bufferMu.Unlock()
// 写入文件
l.writeToFile(entry)
// 通知订阅者
l.notifySubscribers(entry)
}
// Subscribe 订阅日志流
func (l *Logger) Subscribe(sessionID string) <-chan protocol.LogEntry {
ch := make(chan protocol.LogEntry, 100)
l.subMu.Lock()
l.subscribers[sessionID] = ch
l.subMu.Unlock()
return ch
}
// Unsubscribe 取消订阅
func (l *Logger) Unsubscribe(sessionID string) {
l.subMu.Lock()
if ch, ok := l.subscribers[sessionID]; ok {
close(ch)
delete(l.subscribers, sessionID)
}
l.subMu.Unlock()
}
// GetRecentLogs 获取最近的日志
func (l *Logger) GetRecentLogs(lines int, level string) []protocol.LogEntry {
l.bufferMu.RLock()
defer l.bufferMu.RUnlock()
var entries []protocol.LogEntry
l.buffer.Do(func(v interface{}) {
if v == nil {
return
}
entry := v.(protocol.LogEntry)
// 应用级别过滤
if level != "" && entry.Level != level {
return
}
entries = append(entries, entry)
})
// 如果指定了行数,返回最后 N 行
if lines > 0 && len(entries) > lines {
entries = entries[len(entries)-lines:]
}
return entries
}
// Close 关闭日志收集器
func (l *Logger) Close() {
l.fileMu.Lock()
if l.file != nil {
l.file.Close()
l.file = nil
}
l.fileMu.Unlock()
l.subMu.Lock()
for _, ch := range l.subscribers {
close(ch)
}
l.subscribers = make(map[string]chan protocol.LogEntry)
l.subMu.Unlock()
}
func (l *Logger) writeToFile(entry protocol.LogEntry) {
l.fileMu.Lock()
defer l.fileMu.Unlock()
today := time.Now().Format("2006-01-02")
if l.fileDate != today {
if l.file != nil {
l.file.Close()
}
logPath := filepath.Join(l.dataDir, "logs", fmt.Sprintf(logFilePattern, today))
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return
}
l.file = f
l.fileDate = today
}
if l.file != nil {
fmt.Fprintf(l.file, "%d|%s|%s|%s\n",
entry.Timestamp, entry.Level, entry.Source, entry.Message)
}
}
func (l *Logger) notifySubscribers(entry protocol.LogEntry) {
l.subMu.RLock()
defer l.subMu.RUnlock()
for _, ch := range l.subscribers {
select {
case ch <- entry:
default:
// 订阅者太慢,丢弃日志
}
}
}
func levelToString(level LogLevel) string {
switch level {
case LevelDebug:
return "debug"
case LevelInfo:
return "info"
case LevelWarn:
return "warn"
case LevelError:
return "error"
default:
return "info"
}
}

View File

@@ -2,10 +2,8 @@ package app
import (
"embed"
"io"
"io/fs"
"log"
"net/http"
"github.com/gotunnel/internal/server/config"
"github.com/gotunnel/internal/server/db"
@@ -13,36 +11,9 @@ import (
"github.com/gotunnel/pkg/auth"
)
//go:embed dist/*
//go:embed all:dist/*
var staticFiles embed.FS
// spaHandler SPA路由处理器
type spaHandler struct {
fs http.FileSystem
}
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
f, err := h.fs.Open(path)
if err != nil {
f, err = h.fs.Open("index.html")
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
}
defer f.Close()
stat, _ := f.Stat()
if stat.IsDir() {
f, err = h.fs.Open(path + "/index.html")
if err != nil {
f, _ = h.fs.Open("index.html")
}
}
http.ServeContent(w, r, path, stat.ModTime(), f.(io.ReadSeeker))
}
// WebServer Web控制台服务
type WebServer struct {
ClientStore db.ClientStore
@@ -63,36 +34,29 @@ func NewWebServer(cs db.ClientStore, srv router.ServerInterface, cfg *config.Ser
}
}
// Run 启动Web服务
// Run 启动Web服务 (无认证,仅用于开发)
func (w *WebServer) Run(addr string) error {
r := router.New()
router.RegisterRoutes(r, w)
// 使用默认凭据和 JWT
jwtAuth := auth.NewJWTAuth("dev-secret", 24)
r.SetupRoutes(w, jwtAuth, "admin", "admin")
// 静态文件
staticFS, err := fs.Sub(staticFiles, "dist")
if err != nil {
return err
}
r.Handle("/", spaHandler{fs: http.FS(staticFS)})
r.SetupStaticFiles(staticFS)
log.Printf("[Web] Console listening on %s", addr)
return http.ListenAndServe(addr, r.Handler())
return r.Engine.Run(addr)
}
// RunWithAuth 启动带认证的Web服务
// RunWithAuth 启动带 Basic Auth 的 Web 服务 (已废弃,使用 RunWithJWT)
func (w *WebServer) RunWithAuth(addr, username, password string) error {
r := router.New()
router.RegisterRoutes(r, w)
staticFS, err := fs.Sub(staticFiles, "dist")
if err != nil {
return err
}
r.Handle("/", spaHandler{fs: http.FS(staticFS)})
auth := &router.AuthConfig{Username: username, Password: password}
handler := router.BasicAuthMiddleware(auth, r.Handler())
log.Printf("[Web] Console listening on %s (auth enabled)", addr)
return http.ListenAndServe(addr, handler)
// 转发到 JWT 认证
return w.RunWithJWT(addr, username, password, "auto-generated-secret")
}
// RunWithJWT 启动带 JWT 认证的 Web 服务
@@ -102,26 +66,18 @@ func (w *WebServer) RunWithJWT(addr, username, password, jwtSecret string) error
// JWT 认证器
jwtAuth := auth.NewJWTAuth(jwtSecret, 24) // 24小时过期
// 注册认证路由(不需要认证)
authHandler := router.NewAuthHandler(username, password, jwtAuth)
router.RegisterAuthRoutes(r, authHandler)
// 注册业务路由
router.RegisterRoutes(r, w)
// 设置所有路由
r.SetupRoutes(w, jwtAuth, username, password)
// 静态文件
staticFS, err := fs.Sub(staticFiles, "dist")
if err != nil {
return err
}
r.Handle("/", spaHandler{fs: http.FS(staticFS)})
// JWT 中间件,只对 /api/ 路径进行认证(排除 /api/auth/
skipPaths := []string{"/api/auth/"}
handler := router.JWTMiddleware(jwtAuth, skipPaths, r.Handler())
r.SetupStaticFiles(staticFS)
log.Printf("[Web] Console listening on %s (JWT auth enabled)", addr)
return http.ListenAndServe(addr, handler)
return r.Engine.Run(addr)
}
// GetClientStore 获取客户端存储

View File

@@ -2,12 +2,30 @@ package db
import "github.com/gotunnel/pkg/protocol"
// ConfigField 配置字段定义
type ConfigField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"`
Default string `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
Options []string `json:"options,omitempty"`
Description string `json:"description,omitempty"`
}
// ClientPlugin 客户端已安装的插件
type ClientPlugin struct {
Name string `json:"name"`
Version string `json:"version"`
Enabled bool `json:"enabled"`
Config map[string]string `json:"config,omitempty"` // 插件配置
ID string `json:"id"` // 插件实例唯一 ID
Name string `json:"name"`
Version string `json:"version"`
Enabled bool `json:"enabled"`
Running bool `json:"running"` // 运行状态
Config map[string]string `json:"config,omitempty"` // 插件配置
RemotePort int `json:"remote_port,omitempty"` // 远程监听端口
ConfigSchema []ConfigField `json:"config_schema,omitempty"` // 配置模式
AuthEnabled bool `json:"auth_enabled,omitempty"` // 是否启用认证
AuthUsername string `json:"auth_username,omitempty"` // 认证用户名
AuthPassword string `json:"auth_password,omitempty"` // 认证密码
}
// Client 客户端数据
@@ -18,21 +36,6 @@ type Client struct {
Plugins []ClientPlugin `json:"plugins,omitempty"` // 已安装的插件
}
// PluginData 插件数据
type PluginData struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
Source string `json:"source"`
Description string `json:"description"`
Author string `json:"author"`
Icon string `json:"icon"`
Checksum string `json:"checksum"`
Size int64 `json:"size"`
Enabled bool `json:"enabled"`
WASMData []byte `json:"-"`
}
// JSPlugin JS 插件数据
type JSPlugin struct {
Name string `json:"name"`
@@ -40,6 +43,7 @@ type JSPlugin struct {
Signature string `json:"signature"` // 官方签名 (Base64)
Description string `json:"description"`
Author string `json:"author"`
Version string `json:"version,omitempty"`
AutoPush []string `json:"auto_push"`
Config map[string]string `json:"config"`
AutoStart bool `json:"auto_start"`
@@ -58,16 +62,6 @@ type ClientStore interface {
Close() error
}
// PluginStore 插件存储接口
type PluginStore interface {
GetAllPlugins() ([]PluginData, error)
GetPlugin(name string) (*PluginData, error)
SavePlugin(p *PluginData) error
DeletePlugin(name string) error
SetPluginEnabled(name string, enabled bool) error
GetPluginWASM(name string) ([]byte, error)
}
// JSPluginStore JS 插件存储接口
type JSPluginStore interface {
GetAllJSPlugins() ([]JSPlugin, error)
@@ -75,12 +69,12 @@ type JSPluginStore interface {
SaveJSPlugin(p *JSPlugin) error
DeleteJSPlugin(name string) error
SetJSPluginEnabled(name string, enabled bool) error
UpdateJSPluginConfig(name string, config map[string]string) error
}
// Store 统一存储接口
type Store interface {
ClientStore
PluginStore
JSPluginStore
Close() error
}

View File

@@ -52,41 +52,21 @@ func (s *SQLiteStore) init() error {
// 迁移:添加 plugins 列
s.db.Exec(`ALTER TABLE clients ADD COLUMN plugins TEXT NOT NULL DEFAULT '[]'`)
// 创建插件表
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS plugins (
name TEXT PRIMARY KEY,
version TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'proxy',
source TEXT NOT NULL DEFAULT 'wasm',
description TEXT,
author TEXT,
icon TEXT,
checksum TEXT,
size INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 1,
wasm_data BLOB
)
`)
if err != nil {
return err
}
// 迁移:添加 icon 列
s.db.Exec(`ALTER TABLE plugins ADD COLUMN icon TEXT`)
// 创建 JS 插件表
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS js_plugins (
name TEXT PRIMARY KEY,
source TEXT NOT NULL,
signature TEXT NOT NULL DEFAULT '',
description TEXT,
author TEXT,
version TEXT DEFAULT '',
auto_push TEXT NOT NULL DEFAULT '[]',
config TEXT NOT NULL DEFAULT '',
config TEXT NOT NULL DEFAULT '{}',
auto_start INTEGER DEFAULT 1,
enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
@@ -95,6 +75,10 @@ func (s *SQLiteStore) init() error {
// 迁移:添加 signature 列
s.db.Exec(`ALTER TABLE js_plugins ADD COLUMN signature TEXT NOT NULL DEFAULT ''`)
// 迁移:添加 version 列
s.db.Exec(`ALTER TABLE js_plugins ADD COLUMN version TEXT DEFAULT ''`)
// 迁移:添加 updated_at 列
s.db.Exec(`ALTER TABLE js_plugins ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP`)
return nil
}
@@ -217,107 +201,6 @@ func (s *SQLiteStore) GetClientRules(id string) ([]protocol.ProxyRule, error) {
return c.Rules, nil
}
// ========== 插件存储方法 ==========
// GetAllPlugins 获取所有插件
func (s *SQLiteStore) GetAllPlugins() ([]PluginData, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`
SELECT name, version, type, source, description, author, icon, checksum, size, enabled
FROM plugins
`)
if err != nil {
return nil, err
}
defer rows.Close()
var plugins []PluginData
for rows.Next() {
var p PluginData
var enabled int
var icon sql.NullString
err := rows.Scan(&p.Name, &p.Version, &p.Type, &p.Source,
&p.Description, &p.Author, &icon, &p.Checksum, &p.Size, &enabled)
if err != nil {
return nil, err
}
p.Enabled = enabled == 1
p.Icon = icon.String
plugins = append(plugins, p)
}
return plugins, nil
}
// GetPlugin 获取单个插件
func (s *SQLiteStore) GetPlugin(name string) (*PluginData, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var p PluginData
var enabled int
var icon sql.NullString
err := s.db.QueryRow(`
SELECT name, version, type, source, description, author, icon, checksum, size, enabled
FROM plugins WHERE name = ?
`, name).Scan(&p.Name, &p.Version, &p.Type, &p.Source,
&p.Description, &p.Author, &icon, &p.Checksum, &p.Size, &enabled)
if err != nil {
return nil, err
}
p.Enabled = enabled == 1
p.Icon = icon.String
return &p, nil
}
// SavePlugin 保存插件
func (s *SQLiteStore) SavePlugin(p *PluginData) error {
s.mu.Lock()
defer s.mu.Unlock()
enabled := 0
if p.Enabled {
enabled = 1
}
_, err := s.db.Exec(`
INSERT OR REPLACE INTO plugins
(name, version, type, source, description, author, icon, checksum, size, enabled, wasm_data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, p.Name, p.Version, p.Type, p.Source, p.Description, p.Author,
p.Icon, p.Checksum, p.Size, enabled, p.WASMData)
return err
}
// DeletePlugin 删除插件
func (s *SQLiteStore) DeletePlugin(name string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM plugins WHERE name = ?`, name)
return err
}
// SetPluginEnabled 设置插件启用状态
func (s *SQLiteStore) SetPluginEnabled(name string, enabled bool) error {
s.mu.Lock()
defer s.mu.Unlock()
val := 0
if enabled {
val = 1
}
_, err := s.db.Exec(`UPDATE plugins SET enabled = ? WHERE name = ?`, val, name)
return err
}
// GetPluginWASM 获取插件 WASM 数据
func (s *SQLiteStore) GetPluginWASM(name string) ([]byte, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var data []byte
err := s.db.QueryRow(`SELECT wasm_data FROM plugins WHERE name = ?`, name).Scan(&data)
return data, err
}
// ========== JS 插件存储方法 ==========
// GetAllJSPlugins 获取所有 JS 插件
@@ -326,7 +209,7 @@ func (s *SQLiteStore) GetAllJSPlugins() ([]JSPlugin, error) {
defer s.mu.RUnlock()
rows, err := s.db.Query(`
SELECT name, source, signature, description, author, auto_push, config, auto_start, enabled
SELECT name, source, signature, description, author, version, auto_push, config, auto_start, enabled
FROM js_plugins
`)
if err != nil {
@@ -338,12 +221,14 @@ func (s *SQLiteStore) GetAllJSPlugins() ([]JSPlugin, error) {
for rows.Next() {
var p JSPlugin
var autoPushJSON, configJSON string
var version sql.NullString
var autoStart, enabled int
err := rows.Scan(&p.Name, &p.Source, &p.Signature, &p.Description, &p.Author,
&autoPushJSON, &configJSON, &autoStart, &enabled)
&version, &autoPushJSON, &configJSON, &autoStart, &enabled)
if err != nil {
return nil, err
}
p.Version = version.String
json.Unmarshal([]byte(autoPushJSON), &p.AutoPush)
json.Unmarshal([]byte(configJSON), &p.Config)
p.AutoStart = autoStart == 1
@@ -360,15 +245,17 @@ func (s *SQLiteStore) GetJSPlugin(name string) (*JSPlugin, error) {
var p JSPlugin
var autoPushJSON, configJSON string
var version sql.NullString
var autoStart, enabled int
err := s.db.QueryRow(`
SELECT name, source, signature, description, author, auto_push, config, auto_start, enabled
SELECT name, source, signature, description, author, version, auto_push, config, auto_start, enabled
FROM js_plugins WHERE name = ?
`, name).Scan(&p.Name, &p.Source, &p.Signature, &p.Description, &p.Author,
&autoPushJSON, &configJSON, &autoStart, &enabled)
&version, &autoPushJSON, &configJSON, &autoStart, &enabled)
if err != nil {
return nil, err
}
p.Version = version.String
json.Unmarshal([]byte(autoPushJSON), &p.AutoPush)
json.Unmarshal([]byte(configJSON), &p.Config)
p.AutoStart = autoStart == 1
@@ -393,9 +280,9 @@ func (s *SQLiteStore) SaveJSPlugin(p *JSPlugin) error {
_, err := s.db.Exec(`
INSERT OR REPLACE INTO js_plugins
(name, source, signature, description, author, auto_push, config, auto_start, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, p.Name, p.Source, p.Signature, p.Description, p.Author,
(name, source, signature, description, author, version, auto_push, config, auto_start, enabled, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`, p.Name, p.Source, p.Signature, p.Description, p.Author, p.Version,
string(autoPushJSON), string(configJSON), autoStart, enabled)
return err
}
@@ -416,6 +303,15 @@ func (s *SQLiteStore) SetJSPluginEnabled(name string, enabled bool) error {
if enabled {
val = 1
}
_, err := s.db.Exec(`UPDATE js_plugins SET enabled = ? WHERE name = ?`, val, name)
_, err := s.db.Exec(`UPDATE js_plugins SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?`, val, name)
return err
}
// UpdateJSPluginConfig 更新 JS 插件配置
func (s *SQLiteStore) UpdateJSPluginConfig(name string, config map[string]string) error {
s.mu.Lock()
defer s.mu.Unlock()
configJSON, _ := json.Marshal(config)
_, err := s.db.Exec(`UPDATE js_plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?`, string(configJSON), name)
return err
}

View File

@@ -1,11 +1,9 @@
package plugin
import (
"log"
"sync"
"github.com/gotunnel/pkg/plugin"
"github.com/gotunnel/pkg/plugin/builtin"
)
// Manager 服务端 plugin 管理器
@@ -22,33 +20,9 @@ func NewManager() (*Manager, error) {
registry: registry,
}
if err := m.registerBuiltins(); err != nil {
return nil, err
}
return m, nil
}
// registerBuiltins 注册内置 plugins
func (m *Manager) registerBuiltins() error {
if err := m.registry.RegisterAllServer(builtin.GetServerPlugins()); err != nil {
return err
}
for _, h := range builtin.GetClientPlugins() {
if err := m.registry.RegisterClient(h); err != nil {
return err
}
}
log.Printf("[Plugin] Registered %d server, %d client plugins",
len(builtin.GetServerPlugins()), len(builtin.GetClientPlugins()))
return nil
}
// GetServer 返回服务端插件
func (m *Manager) GetServer(name string) (plugin.ServerPlugin, error) {
return m.registry.GetServer(name)
}
// ListPlugins 返回所有插件
func (m *Manager) ListPlugins() []plugin.Info {
return m.registry.List()

File diff suppressed because it is too large Load Diff

View File

@@ -1,108 +0,0 @@
package router
import (
"crypto/subtle"
"encoding/json"
"net/http"
"github.com/gotunnel/pkg/auth"
"github.com/gotunnel/pkg/security"
)
// AuthHandler 认证处理器
type AuthHandler struct {
username string
password string
jwtAuth *auth.JWTAuth
}
// NewAuthHandler 创建认证处理器
func NewAuthHandler(username, password string, jwtAuth *auth.JWTAuth) *AuthHandler {
return &AuthHandler{
username: username,
password: password,
jwtAuth: jwtAuth,
}
}
// RegisterAuthRoutes 注册认证路由
func RegisterAuthRoutes(r *Router, h *AuthHandler) {
r.HandleFunc("/api/auth/login", h.handleLogin)
r.HandleFunc("/api/auth/check", h.handleCheck)
}
// handleLogin 处理登录请求
func (h *AuthHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
// 验证用户名密码
userMatch := subtle.ConstantTimeCompare([]byte(req.Username), []byte(h.username)) == 1
passMatch := subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.password)) == 1
if !userMatch || !passMatch {
security.LogWebLogin(r.RemoteAddr, req.Username, false)
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
// 生成 token
token, err := h.jwtAuth.GenerateToken(req.Username)
if err != nil {
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
return
}
security.LogWebLogin(r.RemoteAddr, req.Username, true)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"token": token,
})
}
// handleCheck 检查 token 是否有效
func (h *AuthHandler) handleCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
// 从 Authorization header 获取 token
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
return
}
// 解析 Bearer token
const prefix = "Bearer "
if len(authHeader) < len(prefix) || authHeader[:len(prefix)] != prefix {
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
return
}
tokenStr := authHeader[len(prefix):]
// 验证 token
claims, err := h.jwtAuth.ValidateToken(tokenStr)
if err != nil {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"valid": true,
"username": claims.Username,
})
}

View File

@@ -0,0 +1,58 @@
package dto
import (
"github.com/gotunnel/internal/server/db"
"github.com/gotunnel/pkg/protocol"
)
// CreateClientRequest 创建客户端请求
// @Description 创建新客户端的请求体
type CreateClientRequest struct {
ID string `json:"id" binding:"required,min=1,max=64" example:"client-001"`
Rules []protocol.ProxyRule `json:"rules"`
}
// UpdateClientRequest 更新客户端请求
// @Description 更新客户端配置的请求体
type UpdateClientRequest struct {
Nickname string `json:"nickname" binding:"max=128" example:"My Client"`
Rules []protocol.ProxyRule `json:"rules"`
Plugins []db.ClientPlugin `json:"plugins"`
}
// ClientResponse 客户端详情响应
// @Description 客户端详细信息
type ClientResponse struct {
ID string `json:"id" example:"client-001"`
Nickname string `json:"nickname,omitempty" example:"My Client"`
Rules []protocol.ProxyRule `json:"rules"`
Plugins []db.ClientPlugin `json:"plugins,omitempty"`
Online bool `json:"online" example:"true"`
LastPing string `json:"last_ping,omitempty" example:"2025-01-02T10:30:00Z"`
RemoteAddr string `json:"remote_addr,omitempty" example:"192.168.1.100:54321"`
}
// ClientListItem 客户端列表项
// @Description 客户端列表中的单个项目
type ClientListItem struct {
ID string `json:"id" example:"client-001"`
Nickname string `json:"nickname,omitempty" example:"My Client"`
Online bool `json:"online" example:"true"`
LastPing string `json:"last_ping,omitempty"`
RemoteAddr string `json:"remote_addr,omitempty"`
RuleCount int `json:"rule_count" example:"3"`
}
// InstallPluginsRequest 安装插件到客户端请求
// @Description 安装插件到指定客户端
type InstallPluginsRequest struct {
Plugins []string `json:"plugins" binding:"required,min=1,dive,required" example:"socks5,http-proxy"`
}
// ClientPluginActionRequest 客户端插件操作请求
// @Description 对客户端插件执行操作
type ClientPluginActionRequest struct {
RuleName string `json:"rule_name"`
Config map[string]string `json:"config,omitempty"`
Restart bool `json:"restart"`
}

View File

@@ -0,0 +1,53 @@
package dto
// UpdateServerConfigRequest 更新服务器配置请求
// @Description 更新服务器配置
type UpdateServerConfigRequest struct {
Server *ServerConfigPart `json:"server"`
Web *WebConfigPart `json:"web"`
}
// ServerConfigPart 服务器配置部分
// @Description 隧道服务器配置
type ServerConfigPart struct {
BindAddr string `json:"bind_addr" binding:"omitempty"`
BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"`
Token string `json:"token" binding:"omitempty,min=8"`
HeartbeatSec int `json:"heartbeat_sec" binding:"omitempty,min=1,max=300"`
HeartbeatTimeout int `json:"heartbeat_timeout" binding:"omitempty,min=1,max=600"`
}
// WebConfigPart Web 配置部分
// @Description Web 控制台配置
type WebConfigPart struct {
Enabled bool `json:"enabled"`
BindAddr string `json:"bind_addr" binding:"omitempty"`
BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"`
Username string `json:"username" binding:"omitempty,min=3,max=32"`
Password string `json:"password" binding:"omitempty,min=6,max=64"`
}
// ServerConfigResponse 服务器配置响应
// @Description 服务器配置信息
type ServerConfigResponse struct {
Server ServerConfigInfo `json:"server"`
Web WebConfigInfo `json:"web"`
}
// ServerConfigInfo 服务器配置信息
type ServerConfigInfo struct {
BindAddr string `json:"bind_addr"`
BindPort int `json:"bind_port"`
Token string `json:"token"` // 脱敏后的 token
HeartbeatSec int `json:"heartbeat_sec"`
HeartbeatTimeout int `json:"heartbeat_timeout"`
}
// WebConfigInfo Web 配置信息
type WebConfigInfo struct {
Enabled bool `json:"enabled"`
BindAddr string `json:"bind_addr"`
BindPort int `json:"bind_port"`
Username string `json:"username"`
Password string `json:"password"` // 显示为 ****
}

View File

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

View File

@@ -0,0 +1,76 @@
package dto
// CheckUpdateResponse 检查更新响应
// @Description 更新检查结果
type CheckUpdateResponse struct {
HasUpdate bool `json:"has_update"`
CurrentVersion string `json:"current_version"`
LatestVersion string `json:"latest_version,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
ReleaseNotes string `json:"release_notes,omitempty"`
PublishedAt string `json:"published_at,omitempty"`
}
// CheckClientUpdateQuery 检查客户端更新查询参数
// @Description 检查客户端更新的查询参数
type CheckClientUpdateQuery struct {
OS string `form:"os" binding:"omitempty,oneof=linux darwin windows"`
Arch string `form:"arch" binding:"omitempty,oneof=amd64 arm64 386 arm"`
}
// ApplyServerUpdateRequest 应用服务端更新请求
// @Description 应用服务端更新
type ApplyServerUpdateRequest struct {
DownloadURL string `json:"download_url" binding:"required,url"`
Restart bool `json:"restart"`
}
// ApplyClientUpdateRequest 应用客户端更新请求
// @Description 推送更新到客户端
type ApplyClientUpdateRequest struct {
ClientID string `json:"client_id" binding:"required"`
DownloadURL string `json:"download_url" binding:"required,url"`
}
// VersionInfo 版本信息
// @Description 当前版本信息
type VersionInfo struct {
Version string `json:"version"`
GitCommit string `json:"git_commit,omitempty"`
BuildTime string `json:"build_time,omitempty"`
GoVersion string `json:"go_version,omitempty"`
Platform string `json:"platform,omitempty"`
}
// StatusResponse 服务器状态响应
// @Description 服务器状态信息
type StatusResponse struct {
Server ServerStatus `json:"server"`
ClientCount int `json:"client_count"`
}
// ServerStatus 服务器状态
type ServerStatus struct {
BindAddr string `json:"bind_addr"`
BindPort int `json:"bind_port"`
}
// LoginRequest 登录请求
// @Description 用户登录
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// LoginResponse 登录响应
// @Description 登录成功返回
type LoginResponse struct {
Token string `json:"token"`
}
// TokenCheckResponse Token 检查响应
// @Description Token 验证结果
type TokenCheckResponse struct {
Valid bool `json:"valid"`
Username string `json:"username"`
}

View File

@@ -0,0 +1,106 @@
package router
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
// ValidationError 验证错误详情
type ValidationError struct {
Field string `json:"field"` // 字段名
Message string `json:"message"` // 错误消息
}
// HandleValidationError 处理验证错误并返回统一格式
func HandleValidationError(c *gin.Context, err error) {
var ve validator.ValidationErrors
if errors.As(err, &ve) {
errs := make([]ValidationError, len(ve))
for i, fe := range ve {
errs[i] = ValidationError{
Field: fe.Field(),
Message: getValidationMessage(fe),
}
}
c.JSON(http.StatusBadRequest, Response{
Code: CodeBadRequest,
Message: "validation failed",
Data: errs,
})
return
}
// 非验证错误,返回通用错误消息
BadRequest(c, err.Error())
}
// getValidationMessage 根据验证标签返回友好的错误消息
func getValidationMessage(fe validator.FieldError) string {
switch fe.Tag() {
case "required":
return "this field is required"
case "min":
return "value is too short or too small"
case "max":
return "value is too long or too large"
case "email":
return "invalid email format"
case "url":
return "invalid URL format"
case "oneof":
return "value must be one of: " + fe.Param()
case "alphanum":
return "must contain only letters and numbers"
case "alphanumunicode":
return "must contain only letters, numbers and unicode characters"
case "ip":
return "invalid IP address"
case "hostname":
return "invalid hostname"
case "clientid":
return "must be 1-64 alphanumeric characters, underscore or hyphen"
case "gte":
return "value must be greater than or equal to " + fe.Param()
case "lte":
return "value must be less than or equal to " + fe.Param()
case "gt":
return "value must be greater than " + fe.Param()
case "lt":
return "value must be less than " + fe.Param()
default:
return "validation failed on " + fe.Tag()
}
}
// BindJSON 绑定 JSON 并自动处理验证错误
// 返回 true 表示绑定成功false 表示已处理错误响应
func BindJSON(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindJSON(obj); err != nil {
HandleValidationError(c, err)
return false
}
return true
}
// BindQuery 绑定查询参数并自动处理验证错误
// 返回 true 表示绑定成功false 表示已处理错误响应
func BindQuery(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindQuery(obj); err != nil {
HandleValidationError(c, err)
return false
}
return true
}
// BindURI 绑定 URI 参数并自动处理验证错误
// 返回 true 表示绑定成功false 表示已处理错误响应
func BindURI(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindUri(obj); err != nil {
HandleValidationError(c, err)
return false
}
return true
}

View File

@@ -0,0 +1,100 @@
package handler
import (
"crypto/subtle"
"github.com/gin-gonic/gin"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
"github.com/gotunnel/pkg/auth"
"github.com/gotunnel/pkg/security"
)
// AuthHandler 认证处理器
type AuthHandler struct {
username string
password string
jwtAuth *auth.JWTAuth
}
// NewAuthHandler 创建认证处理器
func NewAuthHandler(username, password string, jwtAuth *auth.JWTAuth) *AuthHandler {
return &AuthHandler{
username: username,
password: password,
jwtAuth: jwtAuth,
}
}
// Login 用户登录
// @Summary 用户登录
// @Description 使用用户名密码登录,返回 JWT token
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body dto.LoginRequest true "登录信息"
// @Success 200 {object} Response{data=dto.LoginResponse}
// @Failure 400 {object} Response
// @Failure 401 {object} Response
// @Router /api/auth/login [post]
func (h *AuthHandler) Login(c *gin.Context) {
var req dto.LoginRequest
if !BindJSON(c, &req) {
return
}
// 验证用户名密码 (使用常量时间比较防止时序攻击)
userMatch := subtle.ConstantTimeCompare([]byte(req.Username), []byte(h.username)) == 1
passMatch := subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.password)) == 1
if !userMatch || !passMatch {
security.LogWebLogin(c.ClientIP(), req.Username, false)
Unauthorized(c, "invalid credentials")
return
}
// 生成 token
token, err := h.jwtAuth.GenerateToken(req.Username)
if err != nil {
InternalError(c, "failed to generate token")
return
}
security.LogWebLogin(c.ClientIP(), req.Username, true)
Success(c, dto.LoginResponse{Token: token})
}
// Check 检查 token 是否有效
// @Summary 检查 Token
// @Description 验证 JWT token 是否有效
// @Tags 认证
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=dto.TokenCheckResponse}
// @Failure 401 {object} Response
// @Router /api/auth/check [get]
func (h *AuthHandler) Check(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
Unauthorized(c, "missing authorization header")
return
}
const prefix = "Bearer "
if len(authHeader) < len(prefix) || authHeader[:len(prefix)] != prefix {
Unauthorized(c, "invalid authorization format")
return
}
tokenStr := authHeader[len(prefix):]
claims, err := h.jwtAuth.ValidateToken(tokenStr)
if err != nil {
Unauthorized(c, "invalid token")
return
}
Success(c, dto.TokenCheckResponse{
Valid: true,
Username: claims.Username,
})
}

View File

@@ -0,0 +1,461 @@
package handler
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/gotunnel/internal/server/db"
"github.com/gotunnel/internal/server/router/dto"
"github.com/gotunnel/pkg/protocol"
)
// ClientHandler 客户端处理器
type ClientHandler struct {
app AppInterface
}
// NewClientHandler 创建客户端处理器
func NewClientHandler(app AppInterface) *ClientHandler {
return &ClientHandler{app: app}
}
// List 获取客户端列表
// @Summary 获取所有客户端
// @Description 返回所有注册客户端的列表及其在线状态
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=[]dto.ClientListItem}
// @Router /api/clients [get]
func (h *ClientHandler) List(c *gin.Context) {
clients, err := h.app.GetClientStore().GetAllClients()
if err != nil {
InternalError(c, err.Error())
return
}
statusMap := h.app.GetServer().GetAllClientStatus()
result := make([]dto.ClientListItem, 0, len(clients))
for _, client := range clients {
item := dto.ClientListItem{
ID: client.ID,
Nickname: client.Nickname,
RuleCount: len(client.Rules),
}
if status, ok := statusMap[client.ID]; ok {
item.Online = status.Online
item.LastPing = status.LastPing
item.RemoteAddr = status.RemoteAddr
}
result = append(result, item)
}
Success(c, result)
}
// Create 创建客户端
// @Summary 创建新客户端
// @Description 创建一个新的客户端配置
// @Tags 客户端
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.CreateClientRequest true "客户端信息"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 409 {object} Response
// @Router /api/clients [post]
func (h *ClientHandler) Create(c *gin.Context) {
var req dto.CreateClientRequest
if !BindJSON(c, &req) {
return
}
// 验证客户端 ID 格式
if !validateClientID(req.ID) {
BadRequest(c, "invalid client id: must be 1-64 alphanumeric characters, underscore or hyphen")
return
}
// 检查客户端是否已存在
exists, _ := h.app.GetClientStore().ClientExists(req.ID)
if exists {
Conflict(c, "client already exists")
return
}
client := &db.Client{ID: req.ID, Rules: req.Rules}
if err := h.app.GetClientStore().CreateClient(client); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Get 获取单个客户端
// @Summary 获取客户端详情
// @Description 获取指定客户端的详细信息
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Success 200 {object} Response{data=dto.ClientResponse}
// @Failure 404 {object} Response
// @Router /api/client/{id} [get]
func (h *ClientHandler) Get(c *gin.Context) {
clientID := c.Param("id")
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
NotFound(c, "client not found")
return
}
online, lastPing, remoteAddr := h.app.GetServer().GetClientStatus(clientID)
// 复制插件列表
plugins := make([]db.ClientPlugin, len(client.Plugins))
copy(plugins, client.Plugins)
// 如果客户端在线,获取实时插件运行状态
if online {
if statusList, err := h.app.GetServer().GetClientPluginStatus(clientID); err == nil {
// 创建运行中插件的映射
runningPlugins := make(map[string]bool)
for _, s := range statusList {
runningPlugins[s.PluginName] = s.Running
}
// 更新插件状态
for i := range plugins {
if running, ok := runningPlugins[plugins[i].Name]; ok {
plugins[i].Running = running
} else {
plugins[i].Running = false
}
}
}
} else {
// 客户端离线时,所有插件都标记为未运行
for i := range plugins {
plugins[i].Running = false
}
}
resp := dto.ClientResponse{
ID: client.ID,
Nickname: client.Nickname,
Rules: client.Rules,
Plugins: plugins,
Online: online,
LastPing: lastPing,
RemoteAddr: remoteAddr,
}
Success(c, resp)
}
// Update 更新客户端
// @Summary 更新客户端配置
// @Description 更新指定客户端的配置信息
// @Tags 客户端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Param request body dto.UpdateClientRequest true "更新内容"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 404 {object} Response
// @Router /api/client/{id} [put]
func (h *ClientHandler) Update(c *gin.Context) {
clientID := c.Param("id")
var req dto.UpdateClientRequest
if !BindJSON(c, &req) {
return
}
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
NotFound(c, "client not found")
return
}
client.Nickname = req.Nickname
client.Rules = req.Rules
if req.Plugins != nil {
client.Plugins = req.Plugins
}
if err := h.app.GetClientStore().UpdateClient(client); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Delete 删除客户端
// @Summary 删除客户端
// @Description 删除指定的客户端配置
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Success 200 {object} Response
// @Failure 404 {object} Response
// @Router /api/client/{id} [delete]
func (h *ClientHandler) Delete(c *gin.Context) {
clientID := c.Param("id")
exists, _ := h.app.GetClientStore().ClientExists(clientID)
if !exists {
NotFound(c, "client not found")
return
}
if err := h.app.GetClientStore().DeleteClient(clientID); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// PushConfig 推送配置到客户端
// @Summary 推送配置
// @Description 将配置推送到在线客户端
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/client/{id}/push [post]
func (h *ClientHandler) PushConfig(c *gin.Context) {
clientID := c.Param("id")
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
ClientNotOnline(c)
return
}
if err := h.app.GetServer().PushConfigToClient(clientID); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Disconnect 断开客户端连接
// @Summary 断开连接
// @Description 强制断开客户端连接
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Success 200 {object} Response
// @Router /api/client/{id}/disconnect [post]
func (h *ClientHandler) Disconnect(c *gin.Context) {
clientID := c.Param("id")
if err := h.app.GetServer().DisconnectClient(clientID); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Restart 重启客户端
// @Summary 重启客户端
// @Description 发送重启命令到客户端
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Success 200 {object} Response
// @Router /api/client/{id}/restart [post]
func (h *ClientHandler) Restart(c *gin.Context) {
clientID := c.Param("id")
if err := h.app.GetServer().RestartClient(clientID); err != nil {
InternalError(c, err.Error())
return
}
SuccessWithMessage(c, gin.H{"status": "ok"}, "client restart initiated")
}
// InstallPlugins 安装插件到客户端
// @Summary 安装插件
// @Description 将指定插件安装到客户端
// @Tags 客户端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Param request body dto.InstallPluginsRequest true "插件列表"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/client/{id}/install-plugins [post]
func (h *ClientHandler) InstallPlugins(c *gin.Context) {
clientID := c.Param("id")
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
ClientNotOnline(c)
return
}
var req dto.InstallPluginsRequest
if !BindJSON(c, &req) {
return
}
if err := h.app.GetServer().InstallPluginsToClient(clientID, req.Plugins); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// PluginAction 客户端插件操作
// @Summary 插件操作
// @Description 对客户端插件执行操作(start/stop/restart/config/delete)
// @Tags 客户端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Param pluginID path string true "插件实例ID"
// @Param action path string true "操作类型" Enums(start, stop, restart, config, delete)
// @Param request body dto.ClientPluginActionRequest false "操作参数"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/client/{id}/plugin/{pluginID}/{action} [post]
func (h *ClientHandler) PluginAction(c *gin.Context) {
clientID := c.Param("id")
pluginID := c.Param("pluginID")
action := c.Param("action")
var req dto.ClientPluginActionRequest
c.ShouldBindJSON(&req) // 忽略错误,使用默认值
// 通过 pluginID 查找插件信息
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
NotFound(c, "client not found")
return
}
var pluginName string
for _, p := range client.Plugins {
if p.ID == pluginID {
pluginName = p.Name
break
}
}
if pluginName == "" {
NotFound(c, "plugin not found")
return
}
if req.RuleName == "" {
req.RuleName = pluginName
}
switch action {
case "start":
err = h.app.GetServer().StartClientPlugin(clientID, pluginID, pluginName, req.RuleName)
case "stop":
err = h.app.GetServer().StopClientPlugin(clientID, pluginID, pluginName, req.RuleName)
case "restart":
err = h.app.GetServer().RestartClientPlugin(clientID, pluginID, pluginName, req.RuleName)
case "config":
if req.Config == nil {
BadRequest(c, "config required")
return
}
err = h.app.GetServer().UpdateClientPluginConfig(clientID, pluginID, pluginName, req.RuleName, req.Config, req.Restart)
case "delete":
err = h.deleteClientPlugin(clientID, pluginID)
default:
BadRequest(c, "unknown action: "+action)
return
}
if err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{
"status": "ok",
"action": action,
"plugin_id": pluginID,
"plugin": pluginName,
})
}
func (h *ClientHandler) deleteClientPlugin(clientID, pluginID string) error {
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
return fmt.Errorf("client not found")
}
var newPlugins []db.ClientPlugin
var pluginName string
var pluginPort int
found := false
for _, p := range client.Plugins {
if p.ID == pluginID {
found = true
pluginName = p.Name
pluginPort = p.RemotePort
continue
}
newPlugins = append(newPlugins, p)
}
if !found {
return fmt.Errorf("plugin %s not found", pluginID)
}
// 删除插件管理的代理规则
var newRules []protocol.ProxyRule
for _, r := range client.Rules {
if r.PluginManaged && r.Name == pluginName {
continue // 跳过此插件的规则
}
newRules = append(newRules, r)
}
// 停止端口监听器
if pluginPort > 0 {
h.app.GetServer().StopPluginRule(clientID, pluginPort)
}
client.Plugins = newPlugins
client.Rules = newRules
return h.app.GetClientStore().UpdateClient(client)
}
// validateClientID 验证客户端 ID 格式
func validateClientID(id string) bool {
if len(id) < 1 || len(id) > 64 {
return false
}
for _, c := range id {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-') {
return false
}
}
return true
}

View File

@@ -0,0 +1,131 @@
package handler
import (
"github.com/gin-gonic/gin"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
)
// ConfigHandler 配置处理器
type ConfigHandler struct {
app AppInterface
}
// NewConfigHandler 创建配置处理器
func NewConfigHandler(app AppInterface) *ConfigHandler {
return &ConfigHandler{app: app}
}
// Get 获取服务器配置
// @Summary 获取配置
// @Description 返回服务器配置(敏感信息脱敏)
// @Tags 配置
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=dto.ServerConfigResponse}
// @Router /api/config [get]
func (h *ConfigHandler) Get(c *gin.Context) {
cfg := h.app.GetConfig()
// Token 脱敏处理只显示前4位
maskedToken := cfg.Server.Token
if len(maskedToken) > 4 {
maskedToken = maskedToken[:4] + "****"
}
resp := dto.ServerConfigResponse{
Server: dto.ServerConfigInfo{
BindAddr: cfg.Server.BindAddr,
BindPort: cfg.Server.BindPort,
Token: maskedToken,
HeartbeatSec: cfg.Server.HeartbeatSec,
HeartbeatTimeout: cfg.Server.HeartbeatTimeout,
},
Web: dto.WebConfigInfo{
Enabled: cfg.Web.Enabled,
BindAddr: cfg.Web.BindAddr,
BindPort: cfg.Web.BindPort,
Username: cfg.Web.Username,
Password: "****",
},
}
Success(c, resp)
}
// Update 更新服务器配置
// @Summary 更新配置
// @Description 更新服务器配置
// @Tags 配置
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.UpdateServerConfigRequest true "配置内容"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/config [put]
func (h *ConfigHandler) Update(c *gin.Context) {
var req dto.UpdateServerConfigRequest
if !BindJSON(c, &req) {
return
}
cfg := h.app.GetConfig()
// 更新 Server 配置
if req.Server != nil {
if req.Server.BindAddr != "" {
cfg.Server.BindAddr = req.Server.BindAddr
}
if req.Server.BindPort > 0 {
cfg.Server.BindPort = req.Server.BindPort
}
if req.Server.Token != "" {
cfg.Server.Token = req.Server.Token
}
if req.Server.HeartbeatSec > 0 {
cfg.Server.HeartbeatSec = req.Server.HeartbeatSec
}
if req.Server.HeartbeatTimeout > 0 {
cfg.Server.HeartbeatTimeout = req.Server.HeartbeatTimeout
}
}
// 更新 Web 配置
if req.Web != nil {
cfg.Web.Enabled = req.Web.Enabled
if req.Web.BindAddr != "" {
cfg.Web.BindAddr = req.Web.BindAddr
}
if req.Web.BindPort > 0 {
cfg.Web.BindPort = req.Web.BindPort
}
cfg.Web.Username = req.Web.Username
cfg.Web.Password = req.Web.Password
}
if err := h.app.SaveConfig(); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Reload 重新加载配置
// @Summary 重新加载配置
// @Description 重新加载服务器配置
// @Tags 配置
// @Produce json
// @Security Bearer
// @Success 200 {object} Response
// @Failure 500 {object} Response
// @Router /api/config/reload [post]
func (h *ConfigHandler) Reload(c *gin.Context) {
if err := h.app.GetServer().ReloadConfig(); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}

View File

@@ -0,0 +1,201 @@
package handler
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/gotunnel/pkg/update"
"github.com/gotunnel/pkg/version"
)
// UpdateInfo 更新信息
type UpdateInfo struct {
Available bool `json:"available"`
Current string `json:"current"`
Latest string `json:"latest"`
ReleaseNote string `json:"release_note"`
DownloadURL string `json:"download_url"`
AssetName string `json:"asset_name"`
AssetSize int64 `json:"asset_size"`
}
// checkUpdateForComponent 检查组件更新
func checkUpdateForComponent(component string) (*UpdateInfo, error) {
release, err := version.GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
latestVersion := release.TagName
currentVersion := version.Version
available := version.CompareVersions(currentVersion, latestVersion) < 0
// 查找对应平台的资产
var downloadURL string
var assetName string
var assetSize int64
if asset := findAssetForPlatform(release.Assets, component, runtime.GOOS, runtime.GOARCH); asset != nil {
downloadURL = asset.BrowserDownloadURL
assetName = asset.Name
assetSize = asset.Size
}
return &UpdateInfo{
Available: available,
Current: currentVersion,
Latest: latestVersion,
ReleaseNote: release.Body,
DownloadURL: downloadURL,
AssetName: assetName,
AssetSize: assetSize,
}, nil
}
// checkClientUpdateForPlatform 检查指定平台的客户端更新
func checkClientUpdateForPlatform(osName, arch string) (*UpdateInfo, error) {
if osName == "" {
osName = runtime.GOOS
}
if arch == "" {
arch = runtime.GOARCH
}
release, err := version.GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
latestVersion := release.TagName
// 查找对应平台的资产
var downloadURL string
var assetName string
var assetSize int64
if asset := findAssetForPlatform(release.Assets, "client", osName, arch); asset != nil {
downloadURL = asset.BrowserDownloadURL
assetName = asset.Name
assetSize = asset.Size
}
return &UpdateInfo{
Available: true,
Current: "",
Latest: latestVersion,
ReleaseNote: release.Body,
DownloadURL: downloadURL,
AssetName: assetName,
AssetSize: assetSize,
}, nil
}
// findAssetForPlatform 在 Release 资产中查找匹配的文件
// CI 格式: gotunnel-server-v1.0.0-linux-amd64.tar.gz
// 或者: gotunnel-client-v1.0.0-windows-amd64.zip
func findAssetForPlatform(assets []version.ReleaseAsset, component, osName, arch string) *version.ReleaseAsset {
prefix := fmt.Sprintf("gotunnel-%s-", component)
suffix := fmt.Sprintf("-%s-%s", osName, arch)
for i := range assets {
name := assets[i].Name
// 检查是否匹配 gotunnel-{component}-{version}-{os}-{arch}.{ext}
if strings.HasPrefix(name, prefix) && strings.Contains(name, suffix) {
return &assets[i]
}
}
return nil
}
// performSelfUpdate 执行自更新
func performSelfUpdate(downloadURL string, restart bool) error {
// 使用共享的下载和解压逻辑
binaryPath, cleanup, err := update.DownloadAndExtract(downloadURL, "server")
if err != nil {
return err
}
defer cleanup()
// 获取当前可执行文件路径
currentPath, err := os.Executable()
if err != nil {
return fmt.Errorf("get executable: %w", err)
}
currentPath, _ = filepath.EvalSymlinks(currentPath)
// Windows 需要特殊处理(运行中的文件无法直接替换)
if runtime.GOOS == "windows" {
return performWindowsUpdate(binaryPath, currentPath, restart)
}
// Linux/Mac: 直接替换
backupPath := currentPath + ".bak"
// 备份当前文件
if err := os.Rename(currentPath, backupPath); err != nil {
return fmt.Errorf("backup current: %w", err)
}
// 复制新文件(不能用 rename可能跨文件系统
if err := update.CopyFile(binaryPath, currentPath); err != nil {
os.Rename(backupPath, currentPath)
return fmt.Errorf("replace binary: %w", err)
}
// 设置执行权限
if err := os.Chmod(currentPath, 0755); err != nil {
os.Rename(backupPath, currentPath)
return fmt.Errorf("chmod new binary: %w", err)
}
// 删除备份
os.Remove(backupPath)
if restart {
restartProcess(currentPath)
}
return nil
}
// performWindowsUpdate Windows 平台更新
func performWindowsUpdate(newFile, currentPath string, restart bool) error {
batchScript := fmt.Sprintf(`@echo off
ping 127.0.0.1 -n 2 > nul
del "%s"
move "%s" "%s"
`, currentPath, newFile, currentPath)
if restart {
batchScript += fmt.Sprintf(`start "" "%s"
`, currentPath)
}
batchScript += "del \"%~f0\"\n"
batchPath := filepath.Join(os.TempDir(), "gotunnel_update.bat")
if err := os.WriteFile(batchPath, []byte(batchScript), 0755); err != nil {
return fmt.Errorf("write batch: %w", err)
}
cmd := exec.Command("cmd", "/C", "start", "/MIN", batchPath)
if err := cmd.Start(); err != nil {
return fmt.Errorf("start batch: %w", err)
}
os.Exit(0)
return nil
}
// restartProcess 重启进程
func restartProcess(path string) {
cmd := exec.Command(path, os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Start()
os.Exit(0)
}

View File

@@ -0,0 +1,98 @@
package handler
import (
"github.com/gotunnel/internal/server/config"
"github.com/gotunnel/internal/server/db"
"github.com/gotunnel/pkg/protocol"
)
// AppInterface 应用接口
type AppInterface interface {
GetClientStore() db.ClientStore
GetServer() ServerInterface
GetConfig() *config.ServerConfig
GetConfigPath() string
SaveConfig() error
GetJSPluginStore() db.JSPluginStore
}
// ServerInterface 服务端接口
type ServerInterface interface {
GetClientStatus(clientID string) (online bool, lastPing string, remoteAddr string)
GetAllClientStatus() map[string]struct {
Online bool
LastPing string
RemoteAddr string
}
ReloadConfig() error
GetBindAddr() string
GetBindPort() int
PushConfigToClient(clientID string) error
DisconnectClient(clientID string) error
GetPluginList() []PluginInfo
EnablePlugin(name string) error
DisablePlugin(name string) error
InstallPluginsToClient(clientID string, plugins []string) error
GetPluginConfigSchema(name string) ([]ConfigField, error)
SyncPluginConfigToClient(clientID string, pluginName string, config map[string]string) error
InstallJSPluginToClient(clientID string, req JSPluginInstallRequest) error
RestartClient(clientID string) error
StartClientPlugin(clientID, pluginID, pluginName, ruleName string) error
StopClientPlugin(clientID, pluginID, pluginName, ruleName string) error
RestartClientPlugin(clientID, pluginID, pluginName, ruleName string) error
UpdateClientPluginConfig(clientID, pluginID, pluginName, ruleName string, config map[string]string, restart bool) error
SendUpdateToClient(clientID, downloadURL string) error
// 日志流
StartClientLogStream(clientID, sessionID string, lines int, follow bool, level string) (<-chan protocol.LogEntry, error)
StopClientLogStream(sessionID string)
// 插件状态查询
GetClientPluginStatus(clientID string) ([]protocol.PluginStatusEntry, error)
// 插件规则管理
StartPluginRule(clientID string, rule protocol.ProxyRule) error
StopPluginRule(clientID string, remotePort int) error
// 端口检查
IsPortAvailable(port int, excludeClientID string) bool
// 插件 API 代理
ProxyPluginAPIRequest(clientID string, req protocol.PluginAPIRequest) (*protocol.PluginAPIResponse, error)
}
// ConfigField 配置字段
type ConfigField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"`
Default string `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
Options []string `json:"options,omitempty"`
Description string `json:"description,omitempty"`
}
// RuleSchema 规则表单模式
type RuleSchema struct {
NeedsLocalAddr bool `json:"needs_local_addr"`
ExtraFields []ConfigField `json:"extra_fields,omitempty"`
}
// PluginInfo 插件信息
type PluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
Description string `json:"description"`
Source string `json:"source"`
Icon string `json:"icon,omitempty"`
Enabled bool `json:"enabled"`
RuleSchema *RuleSchema `json:"rule_schema,omitempty"`
}
// JSPluginInstallRequest JS 插件安装请求
type JSPluginInstallRequest struct {
PluginID string `json:"plugin_id"`
PluginName string `json:"plugin_name"`
Source string `json:"source"`
Signature string `json:"signature"`
RuleName string `json:"rule_name"`
RemotePort int `json:"remote_port"`
Config map[string]string `json:"config"`
AutoStart bool `json:"auto_start"`
}

View File

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

View File

@@ -0,0 +1,96 @@
package handler
import (
"io"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// LogHandler 日志处理器
type LogHandler struct {
app AppInterface
}
// NewLogHandler 创建日志处理器
func NewLogHandler(app AppInterface) *LogHandler {
return &LogHandler{app: app}
}
// StreamLogs 流式传输客户端日志
// @Summary 流式传输客户端日志
// @Description 通过 Server-Sent Events 实时接收客户端日志
// @Tags Logs
// @Produce text/event-stream
// @Security Bearer
// @Param id path string true "客户端 ID"
// @Param lines query int false "初始日志行数" default(100)
// @Param follow query bool false "是否持续推送新日志" default(true)
// @Param level query string false "日志级别过滤 (info, warn, error)"
// @Success 200 {object} protocol.LogEntry
// @Router /api/client/{id}/logs [get]
func (h *LogHandler) StreamLogs(c *gin.Context) {
clientID := c.Param("id")
// 检查客户端是否在线
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
c.JSON(400, gin.H{"code": 400, "message": "client not online"})
return
}
// 解析查询参数
lines := 100
if v := c.Query("lines"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
lines = n
}
}
follow := true
if v := c.Query("follow"); v == "false" {
follow = false
}
level := c.Query("level")
// 生成会话 ID
sessionID := uuid.New().String()
// 启动日志流
logCh, err := h.app.GetServer().StartClientLogStream(clientID, sessionID, lines, follow, level)
if err != nil {
c.JSON(500, gin.H{"code": 500, "message": err.Error()})
return
}
// 设置 SSE 响应头
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no")
// 获取客户端断开信号
clientGone := c.Request.Context().Done()
// 流式传输日志
c.Stream(func(w io.Writer) bool {
select {
case <-clientGone:
h.app.GetServer().StopClientLogStream(sessionID)
return false
case entry, ok := <-logCh:
if !ok {
return false
}
c.SSEvent("log", entry)
return true
case <-time.After(30 * time.Second):
// 发送心跳
c.SSEvent("heartbeat", gin.H{"ts": time.Now().UnixMilli()})
return true
}
})
}

View File

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

View File

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

View File

@@ -0,0 +1,159 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
// Response 统一 API 响应结构
type Response struct {
Code int `json:"code"`
Data interface{} `json:"data,omitempty"`
Message string `json:"message,omitempty"`
}
// 业务错误码定义
const (
CodeSuccess = 0
CodeBadRequest = 400
CodeUnauthorized = 401
CodeForbidden = 403
CodeNotFound = 404
CodeConflict = 409
CodeInternalError = 500
CodeBadGateway = 502
CodeClientNotOnline = 1001
CodePluginNotFound = 1002
CodeInvalidClientID = 1003
CodePluginDisabled = 1004
CodeConfigSyncFailed = 1005
)
// Success 成功响应
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: CodeSuccess,
Data: data,
})
}
// SuccessWithMessage 成功响应带消息
func SuccessWithMessage(c *gin.Context, data interface{}, message string) {
c.JSON(http.StatusOK, Response{
Code: CodeSuccess,
Data: data,
Message: message,
})
}
// Error 错误响应
func Error(c *gin.Context, httpCode int, bizCode int, message string) {
c.JSON(httpCode, Response{
Code: bizCode,
Message: message,
})
}
// BadRequest 400 错误
func BadRequest(c *gin.Context, message string) {
Error(c, http.StatusBadRequest, CodeBadRequest, message)
}
// Unauthorized 401 错误
func Unauthorized(c *gin.Context, message string) {
Error(c, http.StatusUnauthorized, CodeUnauthorized, message)
}
// NotFound 404 错误
func NotFound(c *gin.Context, message string) {
Error(c, http.StatusNotFound, CodeNotFound, message)
}
// Conflict 409 错误
func Conflict(c *gin.Context, message string) {
Error(c, http.StatusConflict, CodeConflict, message)
}
// InternalError 500 错误
func InternalError(c *gin.Context, message string) {
Error(c, http.StatusInternalServerError, CodeInternalError, message)
}
// BadGateway 502 错误
func BadGateway(c *gin.Context, message string) {
Error(c, http.StatusBadGateway, CodeBadGateway, message)
}
// ClientNotOnline 客户端不在线错误
func ClientNotOnline(c *gin.Context) {
Error(c, http.StatusBadRequest, CodeClientNotOnline, "client not online")
}
// PartialSuccess 部分成功响应
func PartialSuccess(c *gin.Context, data interface{}, message string) {
c.JSON(http.StatusOK, Response{
Code: CodeConfigSyncFailed,
Data: data,
Message: message,
})
}
// BindJSON 绑定 JSON 并自动处理验证错误
func BindJSON(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindJSON(obj); err != nil {
handleValidationError(c, err)
return false
}
return true
}
// BindQuery 绑定查询参数并自动处理验证错误
func BindQuery(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindQuery(obj); err != nil {
handleValidationError(c, err)
return false
}
return true
}
// handleValidationError 处理验证错误
func handleValidationError(c *gin.Context, err error) {
var ve validator.ValidationErrors
if errors.As(err, &ve) {
errs := make([]map[string]string, len(ve))
for i, fe := range ve {
errs[i] = map[string]string{
"field": fe.Field(),
"message": getValidationMessage(fe),
}
}
c.JSON(http.StatusBadRequest, Response{
Code: CodeBadRequest,
Message: "validation failed",
Data: errs,
})
return
}
BadRequest(c, err.Error())
}
func getValidationMessage(fe validator.FieldError) string {
switch fe.Tag() {
case "required":
return "this field is required"
case "min":
return "value is too short or too small"
case "max":
return "value is too long or too large"
case "url":
return "invalid URL format"
case "oneof":
return "value must be one of: " + fe.Param()
default:
return "validation failed on " + fe.Tag()
}
}

View File

@@ -0,0 +1,51 @@
package handler
import (
"github.com/gin-gonic/gin"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
)
// StatusHandler 状态处理器
type StatusHandler struct {
app AppInterface
}
// NewStatusHandler 创建状态处理器
func NewStatusHandler(app AppInterface) *StatusHandler {
return &StatusHandler{app: app}
}
// GetStatus 获取服务器状态
// @Summary 获取服务器状态
// @Description 返回服务器运行状态和客户端数量
// @Tags 状态
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=dto.StatusResponse}
// @Router /api/status [get]
func (h *StatusHandler) GetStatus(c *gin.Context) {
clients, _ := h.app.GetClientStore().GetAllClients()
status := dto.StatusResponse{
Server: dto.ServerStatus{
BindAddr: h.app.GetServer().GetBindAddr(),
BindPort: h.app.GetServer().GetBindPort(),
},
ClientCount: len(clients),
}
Success(c, status)
}
// GetVersion 获取版本信息
// @Summary 获取版本信息
// @Description 返回服务器版本信息
// @Tags 状态
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=dto.VersionInfo}
// @Router /api/update/version [get]
func (h *StatusHandler) GetVersion(c *gin.Context) {
Success(c, getVersionInfo())
}

View File

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

View File

@@ -0,0 +1,132 @@
package handler
import (
"github.com/gin-gonic/gin"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
"github.com/gotunnel/pkg/version"
)
// UpdateHandler 更新处理器
type UpdateHandler struct {
app AppInterface
}
// NewUpdateHandler 创建更新处理器
func NewUpdateHandler(app AppInterface) *UpdateHandler {
return &UpdateHandler{app: app}
}
// CheckServer 检查服务端更新
// @Summary 检查服务端更新
// @Description 检查是否有新的服务端版本可用
// @Tags 更新
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=dto.CheckUpdateResponse}
// @Router /api/update/check/server [get]
func (h *UpdateHandler) CheckServer(c *gin.Context) {
updateInfo, err := checkUpdateForComponent("server")
if err != nil {
InternalError(c, err.Error())
return
}
Success(c, updateInfo)
}
// CheckClient 检查客户端更新
// @Summary 检查客户端更新
// @Description 检查是否有新的客户端版本可用
// @Tags 更新
// @Produce json
// @Security Bearer
// @Param os query string false "操作系统" Enums(linux, darwin, windows)
// @Param arch query string false "架构" Enums(amd64, arm64, 386, arm)
// @Success 200 {object} Response{data=dto.CheckUpdateResponse}
// @Router /api/update/check/client [get]
func (h *UpdateHandler) CheckClient(c *gin.Context) {
var query dto.CheckClientUpdateQuery
if !BindQuery(c, &query) {
return
}
updateInfo, err := checkClientUpdateForPlatform(query.OS, query.Arch)
if err != nil {
InternalError(c, err.Error())
return
}
Success(c, updateInfo)
}
// ApplyServer 应用服务端更新
// @Summary 应用服务端更新
// @Description 下载并应用服务端更新,服务器将自动重启
// @Tags 更新
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.ApplyServerUpdateRequest true "更新请求"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/update/apply/server [post]
func (h *UpdateHandler) ApplyServer(c *gin.Context) {
var req dto.ApplyServerUpdateRequest
if !BindJSON(c, &req) {
return
}
// 异步执行更新
go func() {
if err := performSelfUpdate(req.DownloadURL, req.Restart); err != nil {
println("[Update] Server update failed:", err.Error())
}
}()
Success(c, gin.H{
"success": true,
"message": "Update started, server will restart shortly",
})
}
// ApplyClient 应用客户端更新
// @Summary 推送客户端更新
// @Description 向指定客户端推送更新命令
// @Tags 更新
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.ApplyClientUpdateRequest true "更新请求"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/update/apply/client [post]
func (h *UpdateHandler) ApplyClient(c *gin.Context) {
var req dto.ApplyClientUpdateRequest
if !BindJSON(c, &req) {
return
}
// 发送更新命令到客户端
if err := h.app.GetServer().SendUpdateToClient(req.ClientID, req.DownloadURL); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{
"success": true,
"message": "Update command sent to client",
})
}
// getVersionInfo 获取版本信息
func getVersionInfo() dto.VersionInfo {
info := version.GetInfo()
return dto.VersionInfo{
Version: info.Version,
GitCommit: info.GitCommit,
BuildTime: info.BuildTime,
GoVersion: info.GoVersion,
Platform: info.OS + "/" + info.Arch,
}
}

View File

@@ -0,0 +1,28 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
// CORS 跨域中间件
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin == "" {
origin = "*"
}
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Request-ID")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}

View File

@@ -0,0 +1,57 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/gotunnel/pkg/auth"
)
// JWTAuth JWT 认证中间件
func JWTAuth(jwtAuth *auth.JWTAuth) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
// 支持从 query 参数获取 token (用于 SSE 等不支持自定义 header 的场景)
if authHeader == "" {
if token := c.Query("token"); token != "" {
authHeader = "Bearer " + token
}
}
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "missing authorization header",
})
c.Abort()
return
}
if !strings.HasPrefix(authHeader, "Bearer ") {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "invalid authorization format",
})
c.Abort()
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := jwtAuth.ValidateToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "invalid or expired token",
})
c.Abort()
return
}
// 将用户信息存入上下文
c.Set("username", claims.Username)
c.Set("claims", claims)
c.Next()
}
}

View File

@@ -0,0 +1,58 @@
package middleware
import (
"log"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// 静态资源扩展名
var staticExtensions = []string{
".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico",
".woff", ".woff2", ".ttf", ".eot", ".map", ".json", ".html",
}
// isStaticRequest 检查是否是静态资源请求
func isStaticRequest(path string) bool {
// 检查 /assets/ 路径
if strings.HasPrefix(path, "/assets/") {
return true
}
// 检查文件扩展名
for _, ext := range staticExtensions {
if strings.HasSuffix(path, ext) {
return true
}
}
return false
}
// Logger 请求日志中间件
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
method := c.Request.Method
c.Next()
// 跳过静态资源请求的日志
if isStaticRequest(path) {
return
}
latency := time.Since(start)
status := c.Writer.Status()
clientIP := c.ClientIP()
if query != "" {
path = path + "?" + query
}
log.Printf("[API] %s %s %d %v %s",
method, path, status, latency, clientIP)
}
}

View File

@@ -0,0 +1,26 @@
package middleware
import (
"log"
"net/http"
"runtime/debug"
"github.com/gin-gonic/gin"
)
// Recovery 自定义恢复中间件(返回统一格式)
func Recovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
log.Printf("[PANIC] %v\n%s", err, debug.Stack())
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": "internal server error",
})
c.Abort()
}
}()
c.Next()
}
}

View File

@@ -0,0 +1,116 @@
package router
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Response 统一 API 响应结构
type Response struct {
Code int `json:"code"` // 业务状态码: 0=成功, 非0=错误
Data interface{} `json:"data,omitempty"` // 响应数据
Message string `json:"message,omitempty"` // 提示信息
}
// 业务错误码定义
const (
CodeSuccess = 0 // 成功
CodeBadRequest = 400 // 请求参数错误
CodeUnauthorized = 401 // 未授权
CodeForbidden = 403 // 禁止访问
CodeNotFound = 404 // 资源不存在
CodeConflict = 409 // 资源冲突
CodeInternalError = 500 // 服务器内部错误
CodeBadGateway = 502 // 网关错误
// 业务错误码 (1000+)
CodeClientNotOnline = 1001 // 客户端不在线
CodePluginNotFound = 1002 // 插件不存在
CodeInvalidClientID = 1003 // 无效的客户端ID
CodePluginDisabled = 1004 // 插件已禁用
CodeConfigSyncFailed = 1005 // 配置同步失败
)
// Success 成功响应
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: CodeSuccess,
Data: data,
})
}
// SuccessWithMessage 成功响应带消息
func SuccessWithMessage(c *gin.Context, data interface{}, message string) {
c.JSON(http.StatusOK, Response{
Code: CodeSuccess,
Data: data,
Message: message,
})
}
// Error 错误响应
func Error(c *gin.Context, httpCode int, bizCode int, message string) {
c.JSON(httpCode, Response{
Code: bizCode,
Message: message,
})
}
// ErrorWithData 错误响应带数据
func ErrorWithData(c *gin.Context, httpCode int, bizCode int, message string, data interface{}) {
c.JSON(httpCode, Response{
Code: bizCode,
Message: message,
Data: data,
})
}
// BadRequest 400 错误
func BadRequest(c *gin.Context, message string) {
Error(c, http.StatusBadRequest, CodeBadRequest, message)
}
// Unauthorized 401 错误
func Unauthorized(c *gin.Context, message string) {
Error(c, http.StatusUnauthorized, CodeUnauthorized, message)
}
// Forbidden 403 错误
func Forbidden(c *gin.Context, message string) {
Error(c, http.StatusForbidden, CodeForbidden, message)
}
// NotFound 404 错误
func NotFound(c *gin.Context, message string) {
Error(c, http.StatusNotFound, CodeNotFound, message)
}
// Conflict 409 错误
func Conflict(c *gin.Context, message string) {
Error(c, http.StatusConflict, CodeConflict, message)
}
// InternalError 500 错误
func InternalError(c *gin.Context, message string) {
Error(c, http.StatusInternalServerError, CodeInternalError, message)
}
// BadGateway 502 错误
func BadGateway(c *gin.Context, message string) {
Error(c, http.StatusBadGateway, CodeBadGateway, message)
}
// ClientNotOnline 客户端不在线错误
func ClientNotOnline(c *gin.Context) {
Error(c, http.StatusBadRequest, CodeClientNotOnline, "client not online")
}
// PartialSuccess 部分成功响应
func PartialSuccess(c *gin.Context, data interface{}, message string) {
c.JSON(http.StatusOK, Response{
Code: CodeConfigSyncFailed,
Data: data,
Message: message,
})
}

View File

@@ -1,123 +1,201 @@
package router
import (
"crypto/subtle"
"io"
"io/fs"
"net/http"
"strings"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
"github.com/gotunnel/internal/server/router/handler"
"github.com/gotunnel/internal/server/router/middleware"
"github.com/gotunnel/pkg/auth"
)
// Router 路由管理器
type Router struct {
mux *http.ServeMux
// GinRouter Gin 路由管理器
type GinRouter struct {
Engine *gin.Engine
}
// AuthConfig 认证配置
type AuthConfig struct {
Username string
Password string
}
// New 创建路由管理器
func New() *Router {
return &Router{
mux: http.NewServeMux(),
}
}
// Handle 注册路由处理器
func (r *Router) Handle(pattern string, handler http.Handler) {
r.mux.Handle(pattern, handler)
}
// HandleFunc 注册路由处理函数
func (r *Router) HandleFunc(pattern string, handler http.HandlerFunc) {
r.mux.HandleFunc(pattern, handler)
}
// Group 创建路由组
func (r *Router) Group(prefix string) *RouteGroup {
return &RouteGroup{
router: r,
prefix: prefix,
}
}
// RouteGroup 路由组
type RouteGroup struct {
router *Router
prefix string
}
// HandleFunc 注册路由组处理函数
func (g *RouteGroup) HandleFunc(pattern string, handler http.HandlerFunc) {
g.router.mux.HandleFunc(g.prefix+pattern, handler)
// New 创建 Gin 路由管理器
func New() *GinRouter {
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
return &GinRouter{Engine: engine}
}
// Handler 返回 http.Handler
func (r *Router) Handler() http.Handler {
return r.mux
func (r *GinRouter) Handler() http.Handler {
return r.Engine
}
// BasicAuthMiddleware 基础认证中间件
func BasicAuthMiddleware(auth *AuthConfig, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if auth == nil || (auth.Username == "" && auth.Password == "") {
next.ServeHTTP(w, r)
return
}
// SetupRoutes 配置所有路由
func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth, username, password string) {
engine := r.Engine
user, pass, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="GoTunnel"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// 全局中间件
engine.Use(middleware.Recovery())
engine.Use(middleware.Logger())
engine.Use(middleware.CORS())
userMatch := subtle.ConstantTimeCompare([]byte(user), []byte(auth.Username)) == 1
passMatch := subtle.ConstantTimeCompare([]byte(pass), []byte(auth.Password)) == 1
// Swagger 文档
engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
if !userMatch || !passMatch {
w.Header().Set("WWW-Authenticate", `Basic realm="GoTunnel"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// 认证路由 (无需 JWT)
authHandler := handler.NewAuthHandler(username, password, jwtAuth)
engine.POST("/api/auth/login", authHandler.Login)
engine.GET("/api/auth/check", authHandler.Check)
next.ServeHTTP(w, r)
})
// API 路由 (需要 JWT)
api := engine.Group("/api")
api.Use(middleware.JWTAuth(jwtAuth))
{
// 状态
statusHandler := handler.NewStatusHandler(app)
api.GET("/status", statusHandler.GetStatus)
api.GET("/update/version", statusHandler.GetVersion)
// 客户端管理
clientHandler := handler.NewClientHandler(app)
api.GET("/clients", clientHandler.List)
api.POST("/clients", clientHandler.Create)
api.GET("/client/:id", clientHandler.Get)
api.PUT("/client/:id", clientHandler.Update)
api.DELETE("/client/:id", clientHandler.Delete)
api.POST("/client/:id/push", clientHandler.PushConfig)
api.POST("/client/:id/disconnect", clientHandler.Disconnect)
api.POST("/client/:id/restart", clientHandler.Restart)
api.POST("/client/:id/install-plugins", clientHandler.InstallPlugins)
api.POST("/client/:id/plugin/:pluginID/:action", clientHandler.PluginAction)
// 配置管理
configHandler := handler.NewConfigHandler(app)
api.GET("/config", configHandler.Get)
api.PUT("/config", configHandler.Update)
api.POST("/config/reload", configHandler.Reload)
// 插件管理
pluginHandler := handler.NewPluginHandler(app)
api.GET("/plugins", pluginHandler.List)
api.POST("/plugin/:name/enable", pluginHandler.Enable)
api.POST("/plugin/:name/disable", pluginHandler.Disable)
api.GET("/rule-schemas", pluginHandler.GetRuleSchemas)
api.GET("/client-plugin/:clientID/:pluginName/config", pluginHandler.GetClientConfig)
api.PUT("/client-plugin/:clientID/:pluginName/config", pluginHandler.UpdateClientConfig)
// JS 插件管理
jsPluginHandler := handler.NewJSPluginHandler(app)
api.GET("/js-plugins", jsPluginHandler.List)
api.POST("/js-plugins", jsPluginHandler.Create)
api.GET("/js-plugin/:name", jsPluginHandler.Get)
api.PUT("/js-plugin/:name", jsPluginHandler.Update)
api.DELETE("/js-plugin/:name", jsPluginHandler.Delete)
api.POST("/js-plugin/:name/push/:clientID", jsPluginHandler.PushToClient)
// 插件商店
storeHandler := handler.NewStoreHandler(app)
api.GET("/store/plugins", storeHandler.ListPlugins)
api.POST("/store/install", storeHandler.Install)
// 更新管理
updateHandler := handler.NewUpdateHandler(app)
api.GET("/update/check/server", updateHandler.CheckServer)
api.GET("/update/check/client", updateHandler.CheckClient)
api.POST("/update/apply/server", updateHandler.ApplyServer)
api.POST("/update/apply/client", updateHandler.ApplyClient)
// 日志管理
logHandler := handler.NewLogHandler(app)
api.GET("/client/:id/logs", logHandler.StreamLogs)
// 插件 API 代理 (通过 Web API 访问客户端插件)
pluginAPIHandler := handler.NewPluginAPIHandler(app)
api.Any("/client/:id/plugin-api/:pluginID/*route", pluginAPIHandler.ProxyRequest)
}
}
// JWTMiddleware JWT 认证中间件
func JWTMiddleware(jwtAuth *auth.JWTAuth, skipPaths []string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 只对 /api/ 路径进行认证
if !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
// 检查是否跳过认证
for _, path := range skipPaths {
if strings.HasPrefix(r.URL.Path, path) {
next.ServeHTTP(w, r)
return
}
}
// 从 Header 获取 token
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
if _, err := jwtAuth.ValidateToken(token); err != nil {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
// SetupStaticFiles 配置静态文件处理
func (r *GinRouter) SetupStaticFiles(staticFS fs.FS) {
// 使用 NoRoute 处理 SPA 路由
r.Engine.NoRoute(gin.WrapH(&spaHandler{fs: http.FS(staticFS)}))
}
// spaHandler SPA 路由处理器
type spaHandler struct {
fs http.FileSystem
}
func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// API 请求不应该返回 SPA 页面
if len(path) >= 4 && path[:4] == "/api" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"code":404,"message":"Not Found"}`))
return
}
// 尝试打开请求的文件
f, err := h.fs.Open(path)
if err != nil {
// 文件不存在时,检查是否是静态资源请求
// 静态资源js, css, 图片等)应该返回 404而不是 index.html
if isStaticAsset(path) {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
// 其他路径返回 index.htmlSPA 路由)
f, err = h.fs.Open("index.html")
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if stat.IsDir() {
f.Close()
f, err = h.fs.Open(path + "/index.html")
if err != nil {
f, _ = h.fs.Open("index.html")
}
stat, _ = f.Stat()
}
if seeker, ok := f.(io.ReadSeeker); ok {
http.ServeContent(w, r, path, stat.ModTime(), seeker)
}
}
// isStaticAsset 检查路径是否是静态资源
func isStaticAsset(path string) bool {
staticExtensions := []string{
".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico",
".woff", ".woff2", ".ttf", ".eot", ".map", ".json",
}
for _, ext := range staticExtensions {
if len(path) > len(ext) && path[len(path)-len(ext):] == ext {
return true
}
}
return false
}
// Re-export types from handler package for backward compatibility
type (
ServerInterface = handler.ServerInterface
AppInterface = handler.AppInterface
ConfigField = handler.ConfigField
RuleSchema = handler.RuleSchema
PluginInfo = handler.PluginInfo
JSPluginInstallRequest = handler.JSPluginInstallRequest
)

View File

@@ -0,0 +1,155 @@
package tunnel
import (
"net"
"sync"
"github.com/gotunnel/pkg/protocol"
)
// LogSessionManager 管理所有活跃的日志会话
type LogSessionManager struct {
sessions map[string]*LogSession
mu sync.RWMutex
}
// LogSession 日志流会话
type LogSession struct {
ID string
ClientID string
Stream net.Conn
listeners []chan protocol.LogEntry
mu sync.Mutex
closed bool
}
// NewLogSessionManager 创建日志会话管理器
func NewLogSessionManager() *LogSessionManager {
return &LogSessionManager{
sessions: make(map[string]*LogSession),
}
}
// CreateSession 创建日志会话
func (m *LogSessionManager) CreateSession(clientID, sessionID string, stream net.Conn) *LogSession {
session := &LogSession{
ID: sessionID,
ClientID: clientID,
Stream: stream,
listeners: make([]chan protocol.LogEntry, 0),
}
m.mu.Lock()
m.sessions[sessionID] = session
m.mu.Unlock()
return session
}
// GetSession 获取会话
func (m *LogSessionManager) GetSession(sessionID string) *LogSession {
m.mu.RLock()
defer m.mu.RUnlock()
return m.sessions[sessionID]
}
// RemoveSession 移除会话
func (m *LogSessionManager) RemoveSession(sessionID string) {
m.mu.Lock()
if session, ok := m.sessions[sessionID]; ok {
session.Close()
delete(m.sessions, sessionID)
}
m.mu.Unlock()
}
// GetSessionsByClient 获取客户端的所有会话
func (m *LogSessionManager) GetSessionsByClient(clientID string) []*LogSession {
m.mu.RLock()
defer m.mu.RUnlock()
var sessions []*LogSession
for _, session := range m.sessions {
if session.ClientID == clientID {
sessions = append(sessions, session)
}
}
return sessions
}
// CleanupClientSessions 清理客户端的所有会话
func (m *LogSessionManager) CleanupClientSessions(clientID string) {
m.mu.Lock()
defer m.mu.Unlock()
for id, session := range m.sessions {
if session.ClientID == clientID {
session.Close()
delete(m.sessions, id)
}
}
}
// AddListener 添加监听器
func (s *LogSession) AddListener() <-chan protocol.LogEntry {
ch := make(chan protocol.LogEntry, 100)
s.mu.Lock()
s.listeners = append(s.listeners, ch)
s.mu.Unlock()
return ch
}
// RemoveListener 移除监听器
func (s *LogSession) RemoveListener(ch <-chan protocol.LogEntry) {
s.mu.Lock()
defer s.mu.Unlock()
for i, listener := range s.listeners {
if listener == ch {
close(listener)
s.listeners = append(s.listeners[:i], s.listeners[i+1:]...)
break
}
}
}
// Broadcast 广播日志条目到所有监听器
func (s *LogSession) Broadcast(entry protocol.LogEntry) {
s.mu.Lock()
defer s.mu.Unlock()
for _, ch := range s.listeners {
select {
case ch <- entry:
default:
// 监听器太慢,丢弃日志
}
}
}
// Close 关闭会话
func (s *LogSession) Close() {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return
}
s.closed = true
for _, ch := range s.listeners {
close(ch)
}
s.listeners = nil
if s.Stream != nil {
s.Stream.Close()
}
}
// IsClosed 检查会话是否已关闭
func (s *LogSession) IsClosed() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.closed
}

View File

@@ -3,11 +3,13 @@ package tunnel
import (
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"fmt"
"log"
"net"
"regexp"
"strings"
"sync"
"time"
@@ -48,6 +50,7 @@ func generateClientID() string {
// Server 隧道服务端
type Server struct {
clientStore db.ClientStore
jsPluginStore db.JSPluginStore // JS 插件存储
bindAddr string
bindPort int
token string
@@ -64,6 +67,7 @@ type Server struct {
listener net.Listener // 主监听器
shutdown chan struct{} // 关闭信号
wg sync.WaitGroup // 等待所有连接关闭
logSessions *LogSessionManager // 日志会话管理器
}
// JSPluginEntry JS 插件条目
@@ -101,6 +105,7 @@ func NewServer(cs db.ClientStore, bindAddr string, bindPort int, token string, h
clients: make(map[string]*ClientSession),
connSem: make(chan struct{}, maxConnections),
shutdown: make(chan struct{}),
logSessions: NewLogSessionManager(),
}
}
@@ -147,6 +152,11 @@ func (s *Server) SetPluginRegistry(registry *plugin.Registry) {
s.pluginRegistry = registry
}
// SetJSPluginStore 设置 JS 插件存储
func (s *Server) SetJSPluginStore(store db.JSPluginStore) {
s.jsPluginStore = store
}
// LoadJSPlugins 加载 JS 插件配置
func (s *Server) LoadJSPlugins(plugins []JSPluginEntry) {
s.jsPlugins = plugins
@@ -465,21 +475,7 @@ func (s *Server) acceptProxyConns(cs *ClientSession, ln net.Listener, rule proto
func (s *Server) acceptProxyServerConns(cs *ClientSession, ln net.Listener, rule protocol.ProxyRule) {
dialer := proxy.NewTunnelDialer(cs.Session)
// 优先使用插件系统
if s.pluginRegistry != nil {
if handler, err := s.pluginRegistry.GetServer(rule.Type); err == nil {
handler.Init(rule.PluginConfig)
for {
conn, err := ln.Accept()
if err != nil {
return
}
go handler.HandleConn(conn, dialer)
}
}
}
// 回退到内置 proxy 实现
// 使用内置 proxy 实现
proxyServer := proxy.NewServer(rule.Type, dialer)
for {
conn, err := ln.Accept()
@@ -580,6 +576,49 @@ func (s *Server) GetClientStatus(clientID string) (online bool, lastPing string,
return false, "", ""
}
// GetClientPluginStatus 获取客户端插件运行状态
func (s *Server) GetClientPluginStatus(clientID string) ([]protocol.PluginStatusEntry, error) {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("client %s not online", clientID)
}
stream, err := cs.Session.Open()
if err != nil {
return nil, err
}
defer stream.Close()
// 发送查询请求
msg, err := protocol.NewMessage(protocol.MsgTypePluginStatusQuery, nil)
if err != nil {
return nil, err
}
if err := protocol.WriteMessage(stream, msg); err != nil {
return nil, err
}
// 读取响应
resp, err := protocol.ReadMessage(stream)
if err != nil {
return nil, err
}
if resp.Type != protocol.MsgTypePluginStatusQueryResp {
return nil, fmt.Errorf("unexpected response type: %d", resp.Type)
}
var statusResp protocol.PluginStatusQueryResponse
if err := resp.ParsePayload(&statusResp); err != nil {
return nil, err
}
return statusResp.Plugins, nil
}
// GetAllClientStatus 获取所有客户端状态
func (s *Server) GetAllClientStatus() map[string]struct {
Online bool
@@ -765,14 +804,9 @@ func (s *Server) InstallPluginsToClient(clientID string, plugins []string) error
}
}
if !found {
// 获取插件信息
version := "1.0.0"
if handler, err := s.pluginRegistry.GetServer(pluginName); err == nil && handler != nil {
version = handler.Metadata().Version
}
client.Plugins = append(client.Plugins, db.ClientPlugin{
Name: pluginName,
Version: version,
Version: "1.0.0",
Enabled: true,
})
}
@@ -887,7 +921,7 @@ func (s *Server) GetPluginConfigSchema(name string) ([]router.ConfigField, error
return nil, fmt.Errorf("plugin registry not initialized")
}
handler, err := s.pluginRegistry.GetServer(name)
handler, err := s.pluginRegistry.GetClient(name)
if err != nil {
return nil, fmt.Errorf("plugin %s not found", name)
}
@@ -957,6 +991,7 @@ func (s *Server) InstallJSPluginToClient(clientID string, req router.JSPluginIns
defer stream.Close()
installReq := protocol.JSPluginInstallRequest{
PluginID: req.PluginID,
PluginName: req.PluginName,
Source: req.Source,
Signature: req.Signature,
@@ -1013,11 +1048,14 @@ func (s *Server) startClientPluginListener(cs *ClientSession, rule protocol.Prox
return
}
// 发送启动命令到客户端
if err := s.sendClientPluginStart(cs.Session, rule); err != nil {
log.Printf("[Server] Failed to start client plugin %s: %v", rule.Type, err)
s.portManager.Release(rule.RemotePort)
return
// 只有非 JS 插件才需要发送启动命令
// JS 插件已经通过 JSPluginInstall 安装并启动PluginID 不为空表示是 JS 插件
if rule.PluginID == "" {
if err := s.sendClientPluginStart(cs.Session, rule); err != nil {
log.Printf("[Server] Failed to start client plugin %s: %v", rule.Type, err)
s.portManager.Release(rule.RemotePort)
return
}
}
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", rule.RemotePort))
@@ -1091,6 +1129,20 @@ func (s *Server) acceptClientPluginConns(cs *ClientSession, ln net.Listener, rul
func (s *Server) handleClientPluginConn(cs *ClientSession, conn net.Conn, rule protocol.ProxyRule) {
defer conn.Close()
log.Printf("[Server] handleClientPluginConn: plugin=%s, auth=%v", rule.Type, rule.AuthEnabled)
// 如果启用了 HTTP Basic Auth先进行认证
var bufferedData []byte
if rule.AuthEnabled {
authenticated, data := s.checkHTTPBasicAuth(conn, rule.AuthUsername, rule.AuthPassword)
if !authenticated {
log.Printf("[Server] Auth failed for plugin %s", rule.Type)
return
}
bufferedData = data
log.Printf("[Server] Auth success, buffered %d bytes", len(bufferedData))
}
stream, err := cs.Session.Open()
if err != nil {
log.Printf("[Server] Open stream error: %v", err)
@@ -1099,6 +1151,7 @@ func (s *Server) handleClientPluginConn(cs *ClientSession, conn net.Conn, rule p
defer stream.Close()
req := protocol.ClientPluginConnRequest{
PluginID: rule.PluginID,
PluginName: rule.Type,
RuleName: rule.Name,
}
@@ -1107,11 +1160,90 @@ func (s *Server) handleClientPluginConn(cs *ClientSession, conn net.Conn, rule p
return
}
// 如果有缓冲的数据(已读取的 HTTP 请求头),先发送给客户端
if len(bufferedData) > 0 {
if _, err := stream.Write(bufferedData); err != nil {
return
}
}
relay.Relay(conn, stream)
}
// checkHTTPBasicAuth 检查 HTTP Basic Auth
// 返回 (认证成功, 已读取的数据)
func (s *Server) checkHTTPBasicAuth(conn net.Conn, username, password string) (bool, []byte) {
// 设置读取超时
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
defer conn.SetReadDeadline(time.Time{}) // 重置超时
// 读取 HTTP 请求头
buf := make([]byte, 8192) // 增大缓冲区以处理更大的请求头
n, err := conn.Read(buf)
if err != nil {
return false, nil
}
data := buf[:n]
request := string(data)
// 解析 Authorization 头
authHeader := ""
lines := strings.Split(request, "\r\n")
for _, line := range lines {
if strings.HasPrefix(strings.ToLower(line), "authorization:") {
authHeader = strings.TrimSpace(line[14:])
break
}
}
// 检查 Basic Auth
if authHeader == "" || !strings.HasPrefix(authHeader, "Basic ") {
s.sendHTTPUnauthorized(conn)
return false, nil
}
// 解码 Base64
encoded := authHeader[6:]
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
s.sendHTTPUnauthorized(conn)
return false, nil
}
// 解析 username:password
credentials := string(decoded)
parts := strings.SplitN(credentials, ":", 2)
if len(parts) != 2 {
s.sendHTTPUnauthorized(conn)
return false, nil
}
if parts[0] != username || parts[1] != password {
s.sendHTTPUnauthorized(conn)
return false, nil
}
return true, data
}
// sendHTTPUnauthorized 发送 401 未授权响应
func (s *Server) sendHTTPUnauthorized(conn net.Conn) {
response := "HTTP/1.1 401 Unauthorized\r\n" +
"WWW-Authenticate: Basic realm=\"GoTunnel Plugin\"\r\n" +
"Content-Type: text/plain\r\n" +
"Content-Length: 12\r\n" +
"\r\n" +
"Unauthorized"
conn.Write([]byte(response))
}
// autoPushJSPlugins 自动推送 JS 插件到客户端
func (s *Server) autoPushJSPlugins(cs *ClientSession) {
// 记录已推送的插件,避免重复推送
pushedPlugins := make(map[string]bool)
// 1. 推送配置文件中的 JS 插件
for _, jp := range s.jsPlugins {
if !s.shouldPushToClient(jp.AutoPush, cs.ID) {
continue
@@ -1130,6 +1262,89 @@ func (s *Server) autoPushJSPlugins(cs *ClientSession) {
if err := s.InstallJSPluginToClient(cs.ID, req); err != nil {
log.Printf("[Server] Failed to push JS plugin %s: %v", jp.Name, err)
} else {
pushedPlugins[jp.Name] = true
}
}
// 2. 推送客户端已安装的插件(从数据库)
s.pushClientInstalledPlugins(cs, pushedPlugins)
}
// pushClientInstalledPlugins 推送客户端已安装的插件
func (s *Server) pushClientInstalledPlugins(cs *ClientSession, alreadyPushed map[string]bool) {
if s.jsPluginStore == nil {
return
}
// 获取客户端信息
client, err := s.clientStore.GetClient(cs.ID)
if err != nil {
return
}
// 遍历客户端已安装的插件
for _, cp := range client.Plugins {
if !cp.Enabled {
continue
}
// 跳过已推送的
if alreadyPushed[cp.Name] {
continue
}
// 从 JSPluginStore 获取插件完整信息
jsPlugin, err := s.jsPluginStore.GetJSPlugin(cp.Name)
if err != nil {
log.Printf("[Server] JS plugin %s not found in store: %v", cp.Name, err)
continue
}
log.Printf("[Server] Restoring installed plugin %s to client %s", cp.Name, cs.ID)
// 合并配置(客户端配置优先)
config := jsPlugin.Config
if config == nil {
config = make(map[string]string)
}
for k, v := range cp.Config {
config[k] = v
}
req := router.JSPluginInstallRequest{
PluginID: cp.ID,
PluginName: cp.Name,
Source: jsPlugin.Source,
Signature: jsPlugin.Signature,
RuleName: cp.Name,
Config: config,
AutoStart: jsPlugin.AutoStart,
}
if err := s.InstallJSPluginToClient(cs.ID, req); err != nil {
log.Printf("[Server] Failed to restore plugin %s: %v", cp.Name, err)
} else if cp.RemotePort > 0 {
// 检查端口是否已在监听(避免重复启动)
cs.mu.Lock()
_, exists := cs.Listeners[cp.RemotePort]
cs.mu.Unlock()
if exists {
continue
}
// 安装成功后启动服务端监听器
pluginRule := protocol.ProxyRule{
Name: cp.Name,
Type: cp.Name,
RemotePort: cp.RemotePort,
Enabled: boolPtr(true),
PluginID: cp.ID,
AuthEnabled: cp.AuthEnabled,
AuthUsername: cp.AuthUsername,
AuthPassword: cp.AuthPassword,
}
s.startClientPluginListener(cs, pluginRule)
}
}
}
@@ -1146,3 +1361,496 @@ func (s *Server) shouldPushToClient(autoPush []string, clientID string) bool {
}
return false
}
// RestartClient 重启客户端(通过断开连接,让客户端自动重连)
func (s *Server) RestartClient(clientID string) error {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("client %s not found or not online", clientID)
}
// 发送重启消息
stream, err := cs.Session.Open()
if err != nil {
return err
}
req := protocol.ClientRestartRequest{
Reason: "server requested restart",
}
msg, _ := protocol.NewMessage(protocol.MsgTypeClientRestart, req)
protocol.WriteMessage(stream, msg)
stream.Close()
// 等待一小段时间后断开连接
time.AfterFunc(100*time.Millisecond, func() {
cs.Session.Close()
})
log.Printf("[Server] Restart initiated for client %s", clientID)
return nil
}
// StartClientPlugin 启动客户端插件
func (s *Server) StartClientPlugin(clientID, pluginID, pluginName, ruleName string) error {
s.mu.RLock()
_, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("client %s not found or not online", clientID)
}
// 重新发送安装请求来启动插件
return s.reinstallJSPlugin(clientID, pluginName, ruleName)
}
// StopClientPlugin 停止客户端插件
func (s *Server) StopClientPlugin(clientID, pluginID, pluginName, ruleName string) error {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("client %s not found or not online", clientID)
}
return s.sendClientPluginStop(cs.Session, pluginID, pluginName, ruleName)
}
// sendClientPluginStop 发送客户端插件停止命令
func (s *Server) sendClientPluginStop(session *yamux.Session, pluginID, pluginName, ruleName string) error {
stream, err := session.Open()
if err != nil {
return err
}
defer stream.Close()
req := protocol.ClientPluginStopRequest{
PluginID: pluginID,
PluginName: pluginName,
RuleName: ruleName,
}
msg, err := protocol.NewMessage(protocol.MsgTypeClientPluginStop, req)
if err != nil {
return err
}
if err := protocol.WriteMessage(stream, msg); err != nil {
return err
}
// 等待响应
resp, err := protocol.ReadMessage(stream)
if err != nil {
return err
}
if resp.Type != protocol.MsgTypeClientPluginStatus {
return fmt.Errorf("unexpected response type: %d", resp.Type)
}
var status protocol.ClientPluginStatusResponse
if err := resp.ParsePayload(&status); err != nil {
return err
}
if status.Running {
return fmt.Errorf("plugin still running: %s", status.Error)
}
return nil
}
// StartPluginRule 为客户端插件启动服务端监听器
func (s *Server) StartPluginRule(clientID string, rule protocol.ProxyRule) error {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("client %s not found or not online", clientID)
}
// 检查端口是否已被占用
cs.mu.Lock()
_, exists := cs.Listeners[rule.RemotePort]
cs.mu.Unlock()
if exists {
// 端口已在监听,无需重复启动
return nil
}
// 启动插件监听器
s.startClientPluginListener(cs, rule)
return nil
}
// StopPluginRule 停止客户端插件的服务端监听器
func (s *Server) StopPluginRule(clientID string, remotePort int) error {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return nil // 客户端不在线,无需停止
}
cs.mu.Lock()
if ln, exists := cs.Listeners[remotePort]; exists {
ln.Close()
delete(cs.Listeners, remotePort)
}
cs.mu.Unlock()
s.portManager.Release(remotePort)
return nil
}
// IsPortAvailable 检查端口是否可用
func (s *Server) IsPortAvailable(port int, excludeClientID string) bool {
// 检查系统端口
if !utils.IsPortAvailable(port) {
return false
}
// 检查是否被其他客户端占用
s.mu.RLock()
defer s.mu.RUnlock()
for clientID, cs := range s.clients {
if clientID == excludeClientID {
continue
}
cs.mu.Lock()
_, occupied := cs.Listeners[port]
cs.mu.Unlock()
if occupied {
return false
}
}
return true
}
// ProxyPluginAPIRequest 代理插件 API 请求到客户端
func (s *Server) ProxyPluginAPIRequest(clientID string, req protocol.PluginAPIRequest) (*protocol.PluginAPIResponse, error) {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("client %s not found or not online", clientID)
}
stream, err := cs.Session.Open()
if err != nil {
return nil, fmt.Errorf("open stream: %w", err)
}
defer stream.Close()
// 设置超时30秒
stream.SetDeadline(time.Now().Add(30 * time.Second))
// 发送 API 请求
msg, err := protocol.NewMessage(protocol.MsgTypePluginAPIRequest, req)
if err != nil {
return nil, err
}
if err := protocol.WriteMessage(stream, msg); err != nil {
return nil, err
}
// 读取响应
resp, err := protocol.ReadMessage(stream)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.Type != protocol.MsgTypePluginAPIResponse {
return nil, fmt.Errorf("unexpected response type: %d", resp.Type)
}
var apiResp protocol.PluginAPIResponse
if err := resp.ParsePayload(&apiResp); err != nil {
return nil, err
}
return &apiResp, nil
}
// RestartClientPlugin 重启客户端 JS 插件
func (s *Server) RestartClientPlugin(clientID, pluginID, pluginName, ruleName string) error {
s.mu.RLock()
_, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("client %s not found or not online", clientID)
}
// 重新发送完整的安装请求来重启 JS 插件
return s.reinstallJSPlugin(clientID, pluginName, ruleName)
}
// reinstallJSPlugin 重新安装 JS 插件(用于重启)
func (s *Server) reinstallJSPlugin(clientID, pluginName, ruleName string) error {
// 从数据库获取插件信息
if s.jsPluginStore == nil {
return fmt.Errorf("JS plugin store not configured")
}
jsPlugin, err := s.jsPluginStore.GetJSPlugin(pluginName)
if err != nil {
return fmt.Errorf("plugin %s not found: %w", pluginName, err)
}
// 获取客户端的插件配置
client, err := s.clientStore.GetClient(clientID)
if err != nil {
return fmt.Errorf("client not found: %w", err)
}
// 合并配置并获取 PluginID
config := jsPlugin.Config
if config == nil {
config = make(map[string]string)
}
var pluginID string
for _, cp := range client.Plugins {
if cp.Name == pluginName {
pluginID = cp.ID
for k, v := range cp.Config {
config[k] = v
}
break
}
}
log.Printf("[Server] Reinstalling JS plugin %s (ID: %s) to client %s", pluginName, pluginID, clientID)
req := router.JSPluginInstallRequest{
PluginID: pluginID,
PluginName: pluginName,
Source: jsPlugin.Source,
Signature: jsPlugin.Signature,
RuleName: ruleName,
Config: config,
AutoStart: true, // 重启时总是自动启动
}
return s.InstallJSPluginToClient(clientID, req)
}
// sendJSPluginRestart 发送 JS 插件重启命令
func (s *Server) sendJSPluginRestart(session *yamux.Session, pluginName, ruleName string) error {
stream, err := session.Open()
if err != nil {
return err
}
defer stream.Close()
// 使用 PluginConfigUpdate 消息触发重启
req := protocol.PluginConfigUpdateRequest{
PluginName: pluginName,
RuleName: ruleName,
Config: nil,
Restart: true,
}
msg, err := protocol.NewMessage(protocol.MsgTypePluginConfigUpdate, req)
if err != nil {
return err
}
if err := protocol.WriteMessage(stream, msg); err != nil {
return err
}
// 等待响应
resp, err := protocol.ReadMessage(stream)
if err != nil {
return err
}
var result struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
if err := resp.ParsePayload(&result); err != nil {
return err
}
if !result.Success {
return fmt.Errorf("restart failed: %s", result.Error)
}
log.Printf("[Server] JS plugin %s restarted on client", pluginName)
return nil
}
// UpdateClientPluginConfig 更新客户端插件配置
func (s *Server) UpdateClientPluginConfig(clientID, pluginID, pluginName, ruleName string, config map[string]string, restart bool) error {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("client %s not found or not online", clientID)
}
// 发送配置更新消息
stream, err := cs.Session.Open()
if err != nil {
return err
}
defer stream.Close()
req := protocol.PluginConfigUpdateRequest{
PluginID: pluginID,
PluginName: pluginName,
RuleName: ruleName,
Config: config,
Restart: restart,
}
msg, _ := protocol.NewMessage(protocol.MsgTypePluginConfigUpdate, req)
if err := protocol.WriteMessage(stream, msg); err != nil {
return err
}
// 等待响应
resp, err := protocol.ReadMessage(stream)
if err != nil {
return err
}
var result protocol.PluginConfigUpdateResponse
if err := resp.ParsePayload(&result); err != nil {
return err
}
if !result.Success {
return fmt.Errorf("config update failed: %s", result.Error)
}
return nil
}
// SendUpdateToClient 发送更新命令到客户端
func (s *Server) SendUpdateToClient(clientID, downloadURL string) error {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("client %s not found or not online", clientID)
}
// 发送更新消息
stream, err := cs.Session.Open()
if err != nil {
return err
}
defer stream.Close()
req := protocol.UpdateDownloadRequest{
DownloadURL: downloadURL,
}
msg, _ := protocol.NewMessage(protocol.MsgTypeUpdateDownload, req)
if err := protocol.WriteMessage(stream, msg); err != nil {
return err
}
log.Printf("[Server] Update command sent to client %s: %s", clientID, downloadURL)
return nil
}
// StartClientLogStream 启动客户端日志流
func (s *Server) StartClientLogStream(clientID, sessionID string, lines int, follow bool, level string) (<-chan protocol.LogEntry, error) {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("client %s not found or not online", clientID)
}
// 打开到客户端的流
stream, err := cs.Session.Open()
if err != nil {
return nil, err
}
// 发送日志请求
req := protocol.LogRequest{
SessionID: sessionID,
Lines: lines,
Follow: follow,
Level: level,
}
msg, _ := protocol.NewMessage(protocol.MsgTypeLogRequest, req)
if err := protocol.WriteMessage(stream, msg); err != nil {
stream.Close()
return nil, err
}
// 创建会话
session := s.logSessions.CreateSession(clientID, sessionID, stream)
listener := session.AddListener()
// 启动 goroutine 读取客户端日志
go s.readClientLogs(session, stream)
return listener, nil
}
// readClientLogs 读取客户端日志并广播到监听器
func (s *Server) readClientLogs(session *LogSession, stream net.Conn) {
defer s.logSessions.RemoveSession(session.ID)
for {
msg, err := protocol.ReadMessage(stream)
if err != nil {
return
}
if msg.Type != protocol.MsgTypeLogData {
continue
}
var data protocol.LogData
if err := msg.ParsePayload(&data); err != nil {
continue
}
for _, entry := range data.Entries {
session.Broadcast(entry)
}
if data.EOF {
return
}
}
}
// StopClientLogStream 停止客户端日志流
func (s *Server) StopClientLogStream(sessionID string) {
session := s.logSessions.GetSession(sessionID)
if session == nil {
return
}
// 发送停止请求到客户端
s.mu.RLock()
cs, ok := s.clients[session.ClientID]
s.mu.RUnlock()
if ok {
stream, err := cs.Session.Open()
if err == nil {
req := protocol.LogStopRequest{SessionID: sessionID}
msg, _ := protocol.NewMessage(protocol.MsgTypeLogStop, req)
protocol.WriteMessage(stream, msg)
stream.Close()
}
}
s.logSessions.RemoveSession(sessionID)
}
// boolPtr 返回 bool 值的指针
func boolPtr(b bool) *bool {
return &b
}

View File

@@ -11,7 +11,6 @@ import (
"encoding/hex"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"strings"
@@ -19,6 +18,7 @@ import (
)
// GenerateTLSConfig 生成内存中的自签名证书并返回 TLS 配置
// 证书不限定具体 IP 地址,客户端使用 InsecureSkipVerify 跳过主机名验证(类似 frp
func GenerateTLSConfig() (*tls.Config, error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
@@ -41,8 +41,7 @@ func GenerateTLSConfig() (*tls.Config, error) {
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
DNSNames: []string{"localhost"},
// 不限定 IP 地址和域名,客户端通过 InsecureSkipVerify + TOFU 验证
}
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)

View File

@@ -1,95 +0,0 @@
package builtin
import (
"io"
"log"
"net"
"sync"
"github.com/gotunnel/pkg/plugin"
)
func init() {
RegisterClient(NewEchoPlugin())
}
// EchoPlugin 回显插件 - 客户端插件示例
type EchoPlugin struct {
config map[string]string
listener net.Listener
running bool
mu sync.Mutex
}
// NewEchoPlugin 创建 Echo 插件
func NewEchoPlugin() *EchoPlugin {
return &EchoPlugin{}
}
// Metadata 返回插件信息
func (p *EchoPlugin) Metadata() plugin.Metadata {
return plugin.Metadata{
Name: "echo",
Version: "1.0.0",
Type: plugin.PluginTypeApp,
Source: plugin.PluginSourceBuiltin,
RunAt: plugin.SideClient,
Description: "Echo server (client plugin example)",
Author: "GoTunnel",
RuleSchema: &plugin.RuleSchema{
NeedsLocalAddr: false,
},
}
}
// Init 初始化插件
func (p *EchoPlugin) Init(config map[string]string) error {
p.config = config
return nil
}
// Start 启动服务
func (p *EchoPlugin) Start() (string, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.running {
return "", nil
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", err
}
p.listener = ln
p.running = true
log.Printf("[Echo] Started on %s", ln.Addr().String())
return ln.Addr().String(), nil
}
// HandleConn 处理连接
func (p *EchoPlugin) HandleConn(conn net.Conn) error {
defer conn.Close()
log.Printf("[Echo] New connection from tunnel")
_, err := io.Copy(conn, conn)
return err
}
// Stop 停止服务
func (p *EchoPlugin) Stop() error {
p.mu.Lock()
defer p.mu.Unlock()
if !p.running {
return nil
}
if p.listener != nil {
p.listener.Close()
}
p.running = false
log.Printf("[Echo] Stopped")
return nil
}

View File

@@ -1,28 +0,0 @@
package builtin
import "github.com/gotunnel/pkg/plugin"
var (
serverPlugins []plugin.ServerPlugin
clientPlugins []plugin.ClientPlugin
)
// RegisterServer 注册服务端插件
func RegisterServer(handler plugin.ServerPlugin) {
serverPlugins = append(serverPlugins, handler)
}
// RegisterClient 注册客户端插件
func RegisterClient(handler plugin.ClientPlugin) {
clientPlugins = append(clientPlugins, handler)
}
// GetServerPlugins 返回所有服务端插件
func GetServerPlugins() []plugin.ServerPlugin {
return serverPlugins
}
// GetClientPlugins 返回所有客户端插件
func GetClientPlugins() []plugin.ClientPlugin {
return clientPlugins
}

View File

@@ -1,262 +0,0 @@
package builtin
import (
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"github.com/gotunnel/pkg/plugin"
)
func init() {
RegisterServer(NewSOCKS5Plugin())
}
const (
socks5Version = 0x05
noAuth = 0x00
userPassAuth = 0x02
noAcceptable = 0xFF
userPassAuthVer = 0x01
authSuccess = 0x00
authFailure = 0x01
cmdConnect = 0x01
atypIPv4 = 0x01
atypDomain = 0x03
atypIPv6 = 0x04
)
// SOCKS5Plugin 将现有 SOCKS5 实现封装为 plugin
type SOCKS5Plugin struct {
config map[string]string
}
// NewSOCKS5Plugin 创建 SOCKS5 plugin
func NewSOCKS5Plugin() *SOCKS5Plugin {
return &SOCKS5Plugin{}
}
// Metadata 返回 plugin 信息
func (p *SOCKS5Plugin) Metadata() plugin.Metadata {
return plugin.Metadata{
Name: "socks5",
Version: "1.0.0",
Type: plugin.PluginTypeProxy,
Source: plugin.PluginSourceBuiltin,
RunAt: plugin.SideServer,
Description: "SOCKS5 proxy protocol handler",
Author: "GoTunnel",
RuleSchema: &plugin.RuleSchema{
NeedsLocalAddr: false,
},
ConfigSchema: []plugin.ConfigField{
{
Key: "auth",
Label: "认证方式",
Type: plugin.ConfigFieldSelect,
Default: "none",
Options: []string{"none", "password"},
},
{
Key: "username",
Label: "用户名",
Type: plugin.ConfigFieldString,
},
{
Key: "password",
Label: "密码",
Type: plugin.ConfigFieldPassword,
},
},
}
}
// Init 初始化 plugin
func (p *SOCKS5Plugin) Init(config map[string]string) error {
p.config = config
return nil
}
// HandleConn 处理 SOCKS5 连接
func (p *SOCKS5Plugin) HandleConn(conn net.Conn, dialer plugin.Dialer) error {
defer conn.Close()
// 握手阶段
if err := p.handshake(conn); err != nil {
return err
}
// 获取请求
target, err := p.readRequest(conn)
if err != nil {
return err
}
// 连接目标
remote, err := dialer.Dial("tcp", target)
if err != nil {
p.sendReply(conn, 0x05) // Connection refused
return err
}
defer remote.Close()
// 发送成功响应
if err := p.sendReply(conn, 0x00); err != nil {
return err
}
// 双向转发
go io.Copy(remote, conn)
io.Copy(conn, remote)
return nil
}
// Close 释放资源
func (p *SOCKS5Plugin) Close() error {
return nil
}
// handshake 处理握手
func (p *SOCKS5Plugin) handshake(conn net.Conn) error {
buf := make([]byte, 2)
if _, err := io.ReadFull(conn, buf); err != nil {
return err
}
if buf[0] != socks5Version {
return errors.New("unsupported SOCKS version")
}
nmethods := int(buf[1])
methods := make([]byte, nmethods)
if _, err := io.ReadFull(conn, methods); err != nil {
return err
}
// 检查是否需要密码认证
if p.config["auth"] == "password" {
// 检查客户端是否支持用户名密码认证
supported := false
for _, m := range methods {
if m == userPassAuth {
supported = true
break
}
}
if !supported {
conn.Write([]byte{socks5Version, noAcceptable})
return errors.New("client does not support password auth")
}
// 选择用户名密码认证
if _, err := conn.Write([]byte{socks5Version, userPassAuth}); err != nil {
return err
}
// 执行用户名密码认证
return p.authenticateUserPass(conn)
}
// 无认证
_, err := conn.Write([]byte{socks5Version, noAuth})
return err
}
// readRequest 读取请求
func (p *SOCKS5Plugin) readRequest(conn net.Conn) (string, error) {
buf := make([]byte, 4)
if _, err := io.ReadFull(conn, buf); err != nil {
return "", err
}
if buf[0] != socks5Version || buf[1] != cmdConnect {
return "", errors.New("unsupported command")
}
var host string
switch buf[3] {
case atypIPv4:
ip := make([]byte, 4)
if _, err := io.ReadFull(conn, ip); err != nil {
return "", err
}
host = net.IP(ip).String()
case atypDomain:
lenBuf := make([]byte, 1)
if _, err := io.ReadFull(conn, lenBuf); err != nil {
return "", err
}
domain := make([]byte, lenBuf[0])
if _, err := io.ReadFull(conn, domain); err != nil {
return "", err
}
host = string(domain)
case atypIPv6:
ip := make([]byte, 16)
if _, err := io.ReadFull(conn, ip); err != nil {
return "", err
}
host = net.IP(ip).String()
default:
return "", errors.New("unsupported address type")
}
portBuf := make([]byte, 2)
if _, err := io.ReadFull(conn, portBuf); err != nil {
return "", err
}
port := binary.BigEndian.Uint16(portBuf)
return fmt.Sprintf("%s:%d", host, port), nil
}
// authenticateUserPass 用户名密码认证
func (p *SOCKS5Plugin) authenticateUserPass(conn net.Conn) error {
// 读取认证版本
buf := make([]byte, 2)
if _, err := io.ReadFull(conn, buf); err != nil {
return err
}
if buf[0] != userPassAuthVer {
return errors.New("unsupported auth version")
}
// 读取用户名
ulen := int(buf[1])
username := make([]byte, ulen)
if _, err := io.ReadFull(conn, username); err != nil {
return err
}
// 读取密码长度和密码
plenBuf := make([]byte, 1)
if _, err := io.ReadFull(conn, plenBuf); err != nil {
return err
}
plen := int(plenBuf[0])
password := make([]byte, plen)
if _, err := io.ReadFull(conn, password); err != nil {
return err
}
// 验证用户名密码
expectedUser := p.config["username"]
expectedPass := p.config["password"]
if string(username) == expectedUser && string(password) == expectedPass {
conn.Write([]byte{userPassAuthVer, authSuccess})
return nil
}
conn.Write([]byte{userPassAuthVer, authFailure})
return errors.New("authentication failed")
}
// sendReply 发送响应
func (p *SOCKS5Plugin) sendReply(conn net.Conn, rep byte) error {
reply := []byte{socks5Version, rep, 0x00, atypIPv4, 0, 0, 0, 0, 0, 0}
_, err := conn.Write(reply)
return err
}

View File

@@ -1,95 +0,0 @@
package builtin
import (
"io"
"log"
"net"
"github.com/gotunnel/pkg/plugin"
)
func init() {
RegisterServer(NewVNCPlugin())
}
// VNCPlugin VNC 远程桌面插件
type VNCPlugin struct {
config map[string]string
}
// NewVNCPlugin 创建 VNC plugin
func NewVNCPlugin() *VNCPlugin {
return &VNCPlugin{}
}
// Metadata 返回 plugin 信息
func (p *VNCPlugin) Metadata() plugin.Metadata {
return plugin.Metadata{
Name: "vnc",
Version: "1.0.0",
Type: plugin.PluginTypeApp,
Source: plugin.PluginSourceBuiltin,
RunAt: plugin.SideServer,
Description: "VNC remote desktop relay",
Author: "GoTunnel",
RuleSchema: &plugin.RuleSchema{
NeedsLocalAddr: false,
ExtraFields: []plugin.ConfigField{
{
Key: "vnc_addr",
Label: "VNC 地址",
Type: plugin.ConfigFieldString,
Default: "127.0.0.1:5900",
},
},
},
}
}
// Init 初始化 plugin
func (p *VNCPlugin) Init(config map[string]string) error {
p.config = config
return nil
}
// HandleConn 处理 VNC 连接
// 将外部 VNC 客户端连接转发到客户端本地的 VNC 服务
func (p *VNCPlugin) HandleConn(conn net.Conn, dialer plugin.Dialer) error {
defer conn.Close()
// 默认连接客户端本地的 VNC 服务 (5900)
vncAddr := "127.0.0.1:5900"
if addr, ok := p.config["vnc_addr"]; ok && addr != "" {
vncAddr = addr
}
log.Printf("[VNC] New connection from %s, forwarding to %s", conn.RemoteAddr(), vncAddr)
// 通过隧道连接到客户端本地的 VNC 服务
remote, err := dialer.Dial("tcp", vncAddr)
if err != nil {
log.Printf("[VNC] Failed to connect to %s: %v", vncAddr, err)
return err
}
defer remote.Close()
// 双向转发 VNC 流量
errCh := make(chan error, 2)
go func() {
_, err := io.Copy(remote, conn)
errCh <- err
}()
go func() {
_, err := io.Copy(conn, remote)
errCh <- err
}()
// 等待任一方向完成
<-errCh
return nil
}
// Close 释放资源
func (p *VNCPlugin) Close() error {
return nil
}

View File

@@ -6,9 +6,8 @@ import (
"sync"
)
// Registry 管理可用的 plugins
// Registry 管理可用的 plugins (仅客户端插件)
type Registry struct {
serverPlugins map[string]ServerPlugin // 服务端插件
clientPlugins map[string]ClientPlugin // 客户端插件
enabled map[string]bool // 启用状态
mu sync.RWMutex
@@ -17,31 +16,11 @@ type Registry struct {
// NewRegistry 创建 plugin 注册表
func NewRegistry() *Registry {
return &Registry{
serverPlugins: make(map[string]ServerPlugin),
clientPlugins: make(map[string]ClientPlugin),
enabled: make(map[string]bool),
}
}
// RegisterServer 注册服务端插件
func (r *Registry) RegisterServer(handler ServerPlugin) error {
r.mu.Lock()
defer r.mu.Unlock()
meta := handler.Metadata()
if meta.Name == "" {
return fmt.Errorf("plugin name cannot be empty")
}
if _, exists := r.serverPlugins[meta.Name]; exists {
return fmt.Errorf("plugin %s already registered", meta.Name)
}
r.serverPlugins[meta.Name] = handler
r.enabled[meta.Name] = true
return nil
}
// RegisterClient 注册客户端插件
func (r *Registry) RegisterClient(handler ClientPlugin) error {
r.mu.Lock()
@@ -61,20 +40,6 @@ func (r *Registry) RegisterClient(handler ClientPlugin) error {
return nil
}
// GetServer 返回服务端插件
func (r *Registry) GetServer(name string) (ServerPlugin, error) {
r.mu.RLock()
defer r.mu.RUnlock()
if handler, ok := r.serverPlugins[name]; ok {
if !r.enabled[name] {
return nil, fmt.Errorf("plugin %s is disabled", name)
}
return handler, nil
}
return nil, fmt.Errorf("plugin %s not found", name)
}
// GetClient 返回客户端插件
func (r *Registry) GetClient(name string) (ClientPlugin, error) {
r.mu.RLock()
@@ -96,14 +61,6 @@ func (r *Registry) List() []Info {
var plugins []Info
for name, handler := range r.serverPlugins {
plugins = append(plugins, Info{
Metadata: handler.Metadata(),
Loaded: true,
Enabled: r.enabled[name],
})
}
for name, handler := range r.clientPlugins {
plugins = append(plugins, Info{
Metadata: handler.Metadata(),
@@ -120,9 +77,8 @@ func (r *Registry) Has(name string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, ok1 := r.serverPlugins[name]
_, ok2 := r.clientPlugins[name]
return ok1 || ok2
_, ok := r.clientPlugins[name]
return ok
}
// Close 关闭所有 plugins
@@ -131,11 +87,6 @@ func (r *Registry) Close(ctx context.Context) error {
defer r.mu.Unlock()
var lastErr error
for name, handler := range r.serverPlugins {
if err := handler.Close(); err != nil {
lastErr = fmt.Errorf("failed to close plugin %s: %w", name, err)
}
}
for name, handler := range r.clientPlugins {
if err := handler.Stop(); err != nil {
lastErr = fmt.Errorf("failed to stop client plugin %s: %w", name, err)
@@ -171,9 +122,8 @@ func (r *Registry) Disable(name string) error {
// has 内部检查(无锁)
func (r *Registry) has(name string) bool {
_, ok1 := r.serverPlugins[name]
_, ok2 := r.clientPlugins[name]
return ok1 || ok2
_, ok := r.clientPlugins[name]
return ok
}
// IsEnabled 检查插件是否启用
@@ -182,13 +132,3 @@ func (r *Registry) IsEnabled(name string) bool {
defer r.mu.RUnlock()
return r.enabled[name]
}
// RegisterAllServer 批量注册服务端插件
func (r *Registry) RegisterAllServer(handlers []ServerPlugin) error {
for _, handler := range handlers {
if err := r.RegisterServer(handler); err != nil {
return err
}
}
return nil
}

109
pkg/plugin/schema.go Normal file
View File

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

View File

@@ -1,12 +1,16 @@
package script
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/dop251/goja"
@@ -15,25 +19,34 @@ import (
// JSPlugin JavaScript 脚本插件
type JSPlugin struct {
name string
source string
vm *goja.Runtime
metadata plugin.Metadata
config map[string]string
sandbox *Sandbox
running bool
mu sync.Mutex
name string
source string
vm *goja.Runtime
metadata plugin.Metadata
config map[string]string
sandbox *Sandbox
running bool
mu sync.Mutex
eventListeners map[string][]func(goja.Value)
storagePath string
apiHandlers map[string]map[string]goja.Callable // method -> path -> handler
}
// NewJSPlugin 从 JS 源码创建插件
func NewJSPlugin(name, source string) (*JSPlugin, error) {
p := &JSPlugin{
name: name,
source: source,
vm: goja.New(),
sandbox: DefaultSandbox(),
name: name,
source: source,
vm: goja.New(),
sandbox: DefaultSandbox(),
eventListeners: make(map[string][]func(goja.Value)),
storagePath: filepath.Join("plugin_data", name+".json"),
apiHandlers: make(map[string]map[string]goja.Callable),
}
// 确保存储目录存在
os.MkdirAll("plugin_data", 0755)
if err := p.init(); err != nil {
return nil, err
}
@@ -55,7 +68,21 @@ func (p *JSPlugin) init() error {
// 注入基础 API
p.vm.Set("log", p.jsLog)
// Config API (兼容旧的 config() 调用,同时支持 config.get/getAll)
p.vm.Set("config", p.jsGetConfig)
if configObj := p.vm.Get("config"); configObj != nil {
obj := configObj.ToObject(p.vm)
obj.Set("get", p.jsGetConfig)
obj.Set("getAll", p.jsGetAllConfig)
}
// 注入增强 API
p.vm.Set("logger", p.createLoggerAPI())
p.vm.Set("storage", p.createStorageAPI())
p.vm.Set("event", p.createEventAPI())
p.vm.Set("request", p.createRequestAPI())
p.vm.Set("notify", p.createNotifyAPI())
// 注入文件 API
p.vm.Set("fs", p.createFsAPI())
@@ -63,6 +90,9 @@ func (p *JSPlugin) init() error {
// 注入 HTTP API
p.vm.Set("http", p.createHttpAPI())
// 注入路由 API
p.vm.Set("api", p.createRouteAPI())
// 执行脚本
_, err := p.vm.RunString(p.source)
if err != nil {
@@ -117,7 +147,23 @@ func (p *JSPlugin) Metadata() plugin.Metadata {
// Init 初始化插件配置
func (p *JSPlugin) Init(config map[string]string) error {
p.config = config
p.vm.Set("config", config)
// 根据 root_path 配置设置沙箱允许的路径
if rootPath := config["root_path"]; rootPath != "" {
absPath, err := filepath.Abs(rootPath)
if err == nil {
p.sandbox.AllowedPaths = append(p.sandbox.AllowedPaths, absPath)
p.sandbox.WritablePaths = append(p.sandbox.WritablePaths, absPath)
}
} else {
// 如果没有配置 root_path默认允许访问当前目录
cwd, err := os.Getwd()
if err == nil {
p.sandbox.AllowedPaths = append(p.sandbox.AllowedPaths, cwd)
p.sandbox.WritablePaths = append(p.sandbox.WritablePaths, cwd)
}
}
return nil
}
@@ -146,9 +192,12 @@ func (p *JSPlugin) Start() (string, error) {
func (p *JSPlugin) HandleConn(conn net.Conn) error {
defer conn.Close()
// goja Runtime 不是线程安全的,需要加锁
p.mu.Lock()
defer p.mu.Unlock()
// 创建连接包装器
jsConn := newJSConn(conn)
p.vm.Set("conn", jsConn)
fn, ok := goja.AssertFunction(p.vm.Get("handleConn"))
if !ok {
@@ -362,18 +411,108 @@ func (p *JSPlugin) createHttpAPI() map[string]interface{} {
}
// httpServe 启动 HTTP 服务处理连接
func (p *JSPlugin) httpServe(conn net.Conn, handler func(map[string]interface{}) map[string]interface{}) {
defer conn.Close()
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
func (p *JSPlugin) httpServe(connObj interface{}, handler goja.Callable) {
// 从 jsConn 包装器中提取原始 net.Conn
var conn net.Conn
if jc, ok := connObj.(*jsConn); ok {
conn = jc.conn
} else if nc, ok := connObj.(net.Conn); ok {
conn = nc
} else {
fmt.Printf("[JS:%s] httpServe: invalid conn type: %T\n", p.name, connObj)
return
}
req := parseHTTPRequest(buf[:n])
resp := handler(req)
writeHTTPResponse(conn, resp)
// 注意不要在这里关闭连接HandleConn 会负责关闭
// Use bufio to read the request properly
reader := bufio.NewReader(conn)
for {
// 1. Read Request Line
line, err := reader.ReadString('\n')
if err != nil {
if err != io.EOF {
fmt.Printf("[JS:%s] httpServe read error: %v\n", p.name, err)
}
return
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, " ")
if len(parts) < 2 {
fmt.Printf("[JS:%s] Invalid request line: %s\n", p.name, line)
return
}
method := parts[0]
path := parts[1]
fmt.Printf("[JS:%s] Request: %s %s\n", p.name, method, path)
// 2. Read Headers
headers := make(map[string]string)
contentLength := 0
for {
hLine, err := reader.ReadString('\n')
if err != nil {
break
}
hLine = strings.TrimSpace(hLine)
if hLine == "" {
break
}
if idx := strings.Index(hLine, ":"); idx > 0 {
key := strings.TrimSpace(hLine[:idx])
val := strings.TrimSpace(hLine[idx+1:])
headers[strings.ToLower(key)] = val
if strings.ToLower(key) == "content-length" {
contentLength, _ = strconv.Atoi(val)
}
}
}
// 3. Read Body
body := ""
if contentLength > 0 {
bodyBuf := make([]byte, contentLength)
if _, err := io.ReadFull(reader, bodyBuf); err == nil {
body = string(bodyBuf)
}
}
req := map[string]interface{}{
"method": method,
"path": path,
"headers": headers,
"body": body,
}
// 调用 JS handler 函数
result, err := handler(goja.Undefined(), p.vm.ToValue(req))
if err != nil {
fmt.Printf("[JS:%s] HTTP handler error: %v\n", p.name, err)
conn.Write([]byte("HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n"))
return
}
// 将结果转换为 map
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"))
continue
}
resp := make(map[string]interface{})
respObj := result.ToObject(p.vm)
for _, key := range respObj.Keys() {
val := respObj.Get(key)
resp[key] = val.Export()
}
writeHTTPResponse(conn, resp)
}
}
func (p *JSPlugin) httpJSON(data interface{}) string {
@@ -381,7 +520,18 @@ func (p *JSPlugin) httpJSON(data interface{}) string {
return string(b)
}
func (p *JSPlugin) httpSendFile(conn net.Conn, filePath string) {
func (p *JSPlugin) httpSendFile(connObj interface{}, filePath string) {
// 从 jsConn 包装器中提取原始 net.Conn
var conn net.Conn
if jc, ok := connObj.(*jsConn); ok {
conn = jc.conn
} else if nc, ok := connObj.(net.Conn); ok {
conn = nc
} else {
fmt.Printf("[JS:%s] httpSendFile: invalid conn type: %T\n", p.name, connObj)
return
}
f, err := os.Open(filePath)
if err != nil {
conn.Write([]byte("HTTP/1.1 404 Not Found\r\n\r\n"))
@@ -398,30 +548,9 @@ func (p *JSPlugin) httpSendFile(conn net.Conn, filePath string) {
io.Copy(conn, f)
}
// parseHTTPRequest 解析 HTTP 请求
// parseHTTPRequest is deprecated, logic moved to httpServe
func parseHTTPRequest(data []byte) map[string]interface{} {
lines := string(data)
req := map[string]interface{}{
"method": "GET",
"path": "/",
"body": "",
}
// 解析请求行
if idx := indexOf(lines, " "); idx > 0 {
req["method"] = lines[:idx]
rest := lines[idx+1:]
if idx2 := indexOf(rest, " "); idx2 > 0 {
req["path"] = rest[:idx2]
}
}
// 解析 body
if idx := indexOf(lines, "\r\n\r\n"); idx > 0 {
req["body"] = lines[idx+4:]
}
return req
return nil
}
// writeHTTPResponse 写入 HTTP 响应
@@ -472,3 +601,313 @@ func getContentType(path string) string {
}
return "application/octet-stream"
}
// =============================================================================
// Logger API
// =============================================================================
func (p *JSPlugin) createLoggerAPI() map[string]interface{} {
return map[string]interface{}{
"info": func(msg string) { fmt.Printf("[JS:%s][INFO] %s\n", p.name, msg) },
"warn": func(msg string) { fmt.Printf("[JS:%s][WARN] %s\n", p.name, msg) },
"error": func(msg string) { fmt.Printf("[JS:%s][ERROR] %s\n", p.name, msg) },
}
}
// =============================================================================
// Config API Enhancements
// =============================================================================
func (p *JSPlugin) jsGetAllConfig() map[string]string {
if p.config == nil {
return map[string]string{}
}
return p.config
}
// =============================================================================
// Storage API
// =============================================================================
func (p *JSPlugin) createStorageAPI() map[string]interface{} {
return map[string]interface{}{
"get": p.storageGet,
"set": p.storageSet,
"delete": p.storageDelete,
"keys": p.storageKeys,
}
}
func (p *JSPlugin) loadStorage() map[string]interface{} {
data := make(map[string]interface{})
if _, err := os.Stat(p.storagePath); err == nil {
content, _ := os.ReadFile(p.storagePath)
json.Unmarshal(content, &data)
}
return data
}
func (p *JSPlugin) saveStorage(data map[string]interface{}) {
content, _ := json.MarshalIndent(data, "", " ")
os.WriteFile(p.storagePath, content, 0644)
}
func (p *JSPlugin) storageGet(key string, def interface{}) interface{} {
p.mu.Lock()
defer p.mu.Unlock()
data := p.loadStorage()
if v, ok := data[key]; ok {
return v
}
return def
}
func (p *JSPlugin) storageSet(key string, value interface{}) {
p.mu.Lock()
defer p.mu.Unlock()
data := p.loadStorage()
data[key] = value
p.saveStorage(data)
}
func (p *JSPlugin) storageDelete(key string) {
p.mu.Lock()
defer p.mu.Unlock()
data := p.loadStorage()
delete(data, key)
p.saveStorage(data)
}
func (p *JSPlugin) storageKeys() []string {
p.mu.Lock()
defer p.mu.Unlock()
data := p.loadStorage()
keys := make([]string, 0, len(data))
for k := range data {
keys = append(keys, k)
}
return keys
}
// =============================================================================
// Event API
// =============================================================================
func (p *JSPlugin) createEventAPI() map[string]interface{} {
return map[string]interface{}{
"on": p.eventOn,
"emit": p.eventEmit,
"off": p.eventOff,
}
}
func (p *JSPlugin) eventOn(event string, callback func(goja.Value)) {
p.mu.Lock()
defer p.mu.Unlock()
p.eventListeners[event] = append(p.eventListeners[event], callback)
}
func (p *JSPlugin) eventEmit(event string, data interface{}) {
p.mu.Lock()
listeners := p.eventListeners[event]
p.mu.Unlock() // 释放锁以允许回调中操作
val := p.vm.ToValue(data)
for _, cb := range listeners {
cb(val)
}
}
func (p *JSPlugin) eventOff(event string) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.eventListeners, event)
}
// =============================================================================
// Request API (HTTP Client)
// =============================================================================
func (p *JSPlugin) createRequestAPI() map[string]interface{} {
return map[string]interface{}{
"get": p.requestGet,
"post": p.requestPost,
}
}
func (p *JSPlugin) requestGet(url string) map[string]interface{} {
resp, err := http.Get(url)
if err != nil {
return map[string]interface{}{"error": err.Error(), "status": 0}
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return map[string]interface{}{
"status": resp.StatusCode,
"body": string(body),
"error": "",
}
}
func (p *JSPlugin) requestPost(url string, contentType, data string) map[string]interface{} {
resp, err := http.Post(url, contentType, strings.NewReader(data))
if err != nil {
return map[string]interface{}{"error": err.Error(), "status": 0}
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return map[string]interface{}{
"status": resp.StatusCode,
"body": string(body),
"error": "",
}
}
// =============================================================================
// Notify API
// =============================================================================
func (p *JSPlugin) createNotifyAPI() map[string]interface{} {
return map[string]interface{}{
"send": func(title, msg string) {
// 目前仅打印到日志,后续对接系统通知
fmt.Printf("[NOTIFY][%s] %s: %s\n", p.name, title, msg)
},
}
}
// =============================================================================
// Route API (用于 Web API 代理)
// =============================================================================
func (p *JSPlugin) createRouteAPI() map[string]interface{} {
return map[string]interface{}{
"handle": p.apiHandle,
"get": func(path string, handler goja.Callable) { p.apiRegister("GET", path, handler) },
"post": func(path string, handler goja.Callable) { p.apiRegister("POST", path, handler) },
"put": func(path string, handler goja.Callable) { p.apiRegister("PUT", path, handler) },
"delete": func(path string, handler goja.Callable) { p.apiRegister("DELETE", path, handler) },
}
}
// apiHandle 注册 API 路由处理函数
func (p *JSPlugin) apiHandle(method, path string, handler goja.Callable) {
p.apiRegister(method, path, handler)
}
// apiRegister 注册 API 路由
func (p *JSPlugin) apiRegister(method, path string, handler goja.Callable) {
p.mu.Lock()
defer p.mu.Unlock()
if p.apiHandlers[method] == nil {
p.apiHandlers[method] = make(map[string]goja.Callable)
}
p.apiHandlers[method][path] = handler
fmt.Printf("[JS:%s] Registered API: %s %s\n", p.name, method, path)
}
// HandleAPIRequest 处理 API 请求
func (p *JSPlugin) HandleAPIRequest(method, path, query string, headers map[string]string, body string) (int, map[string]string, string, error) {
p.mu.Lock()
handlers := p.apiHandlers[method]
p.mu.Unlock()
if handlers == nil {
return 404, nil, `{"error":"method not allowed"}`, nil
}
// 查找匹配的路由
var handler goja.Callable
var matchedPath string
for registeredPath, h := range handlers {
if matchRoute(registeredPath, path) {
handler = h
matchedPath = registeredPath
break
}
}
if handler == nil {
return 404, nil, `{"error":"route not found"}`, nil
}
// 构建请求对象
reqObj := map[string]interface{}{
"method": method,
"path": path,
"pattern": matchedPath,
"query": query,
"headers": headers,
"body": body,
"params": extractParams(matchedPath, path),
}
// 调用处理函数
result, err := handler(goja.Undefined(), p.vm.ToValue(reqObj))
if err != nil {
return 500, nil, fmt.Sprintf(`{"error":"%s"}`, err.Error()), nil
}
// 解析响应
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return 200, nil, "", nil
}
respObj := result.ToObject(p.vm)
status := 200
if s := respObj.Get("status"); s != nil && !goja.IsUndefined(s) {
status = int(s.ToInteger())
}
respHeaders := make(map[string]string)
if h := respObj.Get("headers"); h != nil && !goja.IsUndefined(h) {
hObj := h.ToObject(p.vm)
for _, key := range hObj.Keys() {
respHeaders[key] = hObj.Get(key).String()
}
}
respBody := ""
if b := respObj.Get("body"); b != nil && !goja.IsUndefined(b) {
respBody = b.String()
}
return status, respHeaders, respBody, nil
}
// matchRoute 匹配路由 (支持简单的路径参数)
func matchRoute(pattern, path string) bool {
patternParts := strings.Split(strings.Trim(pattern, "/"), "/")
pathParts := strings.Split(strings.Trim(path, "/"), "/")
if len(patternParts) != len(pathParts) {
return false
}
for i, part := range patternParts {
if strings.HasPrefix(part, ":") {
continue // 路径参数,匹配任意值
}
if part != pathParts[i] {
return false
}
}
return true
}
// extractParams 提取路径参数
func extractParams(pattern, path string) map[string]string {
params := make(map[string]string)
patternParts := strings.Split(strings.Trim(pattern, "/"), "/")
pathParts := strings.Split(strings.Trim(path, "/"), "/")
for i, part := range patternParts {
if strings.HasPrefix(part, ":") && i < len(pathParts) {
paramName := strings.TrimPrefix(part, ":")
params[paramName] = pathParts[i]
}
}
return params
}

View File

@@ -13,7 +13,6 @@ import (
type Side string
const (
SideServer Side = "server"
SideClient Side = "client"
)
@@ -100,15 +99,6 @@ type Dialer interface {
Dial(network, address string) (net.Conn, error)
}
// ServerPlugin 服务端插件接口
// 运行在服务端,处理外部连接并通过隧道转发到客户端
type ServerPlugin interface {
Metadata() Metadata
Init(config map[string]string) error
HandleConn(conn net.Conn, dialer Dialer) error
Close() error
}
// ClientPlugin 客户端插件接口
// 运行在客户端,提供本地服务
type ClientPlugin interface {

View File

@@ -40,14 +40,37 @@ const (
MsgTypePluginConfig uint8 = 25 // 插件配置同步
// 客户端插件消息
MsgTypeClientPluginStart uint8 = 40 // 启动客户端插件
MsgTypeClientPluginStop uint8 = 41 // 停止客户端插件
MsgTypeClientPluginStatus uint8 = 42 // 客户端插件状态
MsgTypeClientPluginConn uint8 = 43 // 客户端插件连接请求
MsgTypeClientPluginStart uint8 = 40 // 启动客户端插件
MsgTypeClientPluginStop uint8 = 41 // 停止客户端插件
MsgTypeClientPluginStatus uint8 = 42 // 客户端插件状态
MsgTypeClientPluginConn uint8 = 43 // 客户端插件连接请求
MsgTypePluginStatusQuery uint8 = 44 // 查询所有插件状态
MsgTypePluginStatusQueryResp uint8 = 45 // 插件状态查询响应
// JS 插件动态安装
MsgTypeJSPluginInstall uint8 = 50 // 安装 JS 插件
MsgTypeJSPluginResult uint8 = 51 // 安装结果
// 客户端控制消息
MsgTypeClientRestart uint8 = 60 // 重启客户端
MsgTypePluginConfigUpdate uint8 = 61 // 更新插件配置
// 更新相关消息
MsgTypeUpdateCheck uint8 = 70 // 检查更新请求
MsgTypeUpdateInfo uint8 = 71 // 更新信息响应
MsgTypeUpdateDownload uint8 = 72 // 下载更新请求
MsgTypeUpdateApply uint8 = 73 // 应用更新请求
MsgTypeUpdateProgress uint8 = 74 // 更新进度
MsgTypeUpdateResult uint8 = 75 // 更新结果
// 日志相关消息
MsgTypeLogRequest uint8 = 80 // 请求客户端日志
MsgTypeLogData uint8 = 81 // 日志数据
MsgTypeLogStop uint8 = 82 // 停止日志流
// 插件 API 路由消息
MsgTypePluginAPIRequest uint8 = 90 // 插件 API 请求
MsgTypePluginAPIResponse uint8 = 91 // 插件 API 响应
)
// Message 基础消息结构
@@ -78,9 +101,16 @@ type ProxyRule struct {
RemotePort int `json:"remote_port" yaml:"remote_port"` // 服务端监听端口
Enabled *bool `json:"enabled,omitempty" yaml:"enabled"` // 是否启用,默认为 true
// Plugin 支持字段
PluginID string `json:"plugin_id,omitempty" yaml:"plugin_id"` // 插件实例ID
PluginName string `json:"plugin_name,omitempty" yaml:"plugin_name"`
PluginVersion string `json:"plugin_version,omitempty" yaml:"plugin_version"`
PluginConfig map[string]string `json:"plugin_config,omitempty" yaml:"plugin_config"`
// HTTP Basic Auth 字段 (用于独立端口模式)
AuthEnabled bool `json:"auth_enabled,omitempty" yaml:"auth_enabled"`
AuthUsername string `json:"auth_username,omitempty" yaml:"auth_username"`
AuthPassword string `json:"auth_password,omitempty" yaml:"auth_password"`
// 插件管理标记 - 由插件自动创建的规则,不允许手动编辑/删除
PluginManaged bool `json:"plugin_managed,omitempty" yaml:"plugin_managed"`
}
// IsEnabled 检查规则是否启用,默认为 true
@@ -189,8 +219,9 @@ type ClientPluginStartRequest struct {
// ClientPluginStopRequest 停止客户端插件请求
type ClientPluginStopRequest struct {
PluginName string `json:"plugin_name"` // 插件名称
RuleName string `json:"rule_name"` // 规则名称
PluginID string `json:"plugin_id,omitempty"` // 插件ID优先使用
PluginName string `json:"plugin_name"` // 插件名称
RuleName string `json:"rule_name"` // 规则名称
}
// ClientPluginStatusResponse 客户端插件状态响应
@@ -204,12 +235,25 @@ type ClientPluginStatusResponse struct {
// ClientPluginConnRequest 客户端插件连接请求
type ClientPluginConnRequest struct {
PluginID string `json:"plugin_id,omitempty"` // 插件ID优先使用
PluginName string `json:"plugin_name"` // 插件名称
RuleName string `json:"rule_name"` // 规则名称
}
// PluginStatusEntry 单个插件状态
type PluginStatusEntry struct {
PluginName string `json:"plugin_name"` // 插件名称
RuleName string `json:"rule_name"` // 规则名称
Running bool `json:"running"` // 是否运行中
}
// PluginStatusQueryResponse 插件状态查询响应
type PluginStatusQueryResponse struct {
Plugins []PluginStatusEntry `json:"plugins"` // 所有插件状态
}
// JSPluginInstallRequest JS 插件安装请求
type JSPluginInstallRequest struct {
PluginID string `json:"plugin_id"` // 插件实例唯一 ID
PluginName string `json:"plugin_name"` // 插件名称
Source string `json:"source"` // JS 源码
Signature string `json:"signature"` // 官方签名 (Base64)
@@ -226,6 +270,121 @@ type JSPluginInstallResult struct {
Error string `json:"error,omitempty"`
}
// ClientRestartRequest 客户端重启请求
type ClientRestartRequest struct {
Reason string `json:"reason,omitempty"` // 重启原因
}
// ClientRestartResponse 客户端重启响应
type ClientRestartResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
// PluginConfigUpdateRequest 插件配置更新请求
type PluginConfigUpdateRequest struct {
PluginID string `json:"plugin_id,omitempty"` // 插件ID优先使用
PluginName string `json:"plugin_name"` // 插件名称
RuleName string `json:"rule_name"` // 规则名称
Config map[string]string `json:"config"` // 新配置
Restart bool `json:"restart"` // 是否重启插件
}
// PluginConfigUpdateResponse 插件配置更新响应
type PluginConfigUpdateResponse struct {
PluginName string `json:"plugin_name"`
RuleName string `json:"rule_name"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// UpdateCheckRequest 更新检查请求
type UpdateCheckRequest struct {
Component string `json:"component"` // "server" 或 "client"
}
// UpdateInfoResponse 更新信息响应
type UpdateInfoResponse struct {
Available bool `json:"available"`
Current string `json:"current"`
Latest string `json:"latest"`
ReleaseNote string `json:"release_note"`
DownloadURL string `json:"download_url"`
AssetName string `json:"asset_name"`
AssetSize int64 `json:"asset_size"`
}
// UpdateDownloadRequest 下载更新请求
type UpdateDownloadRequest struct {
DownloadURL string `json:"download_url"`
}
// UpdateApplyRequest 应用更新请求
type UpdateApplyRequest struct {
Restart bool `json:"restart"` // 是否自动重启
}
// UpdateProgressResponse 更新进度响应
type UpdateProgressResponse struct {
Downloaded int64 `json:"downloaded"`
Total int64 `json:"total"`
Percent int `json:"percent"`
Status string `json:"status"` // downloading, applying, completed, failed
}
// UpdateResultResponse 更新结果响应
type UpdateResultResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
// LogRequest 日志请求
type LogRequest struct {
SessionID string `json:"session_id"` // 会话 ID
Lines int `json:"lines"` // 请求的日志行数
Follow bool `json:"follow"` // 是否持续推送新日志
Level string `json:"level"` // 日志级别过滤
}
// LogEntry 日志条目
type LogEntry struct {
Timestamp int64 `json:"ts"` // Unix 时间戳 (毫秒)
Level string `json:"level"` // 日志级别: debug, info, warn, error
Message string `json:"msg"` // 日志消息
Source string `json:"src"` // 来源: client, plugin:<name>
}
// LogData 日志数据
type LogData struct {
SessionID string `json:"session_id"` // 会话 ID
Entries []LogEntry `json:"entries"` // 日志条目
EOF bool `json:"eof"` // 是否结束
}
// LogStopRequest 停止日志流请求
type LogStopRequest struct {
SessionID string `json:"session_id"` // 会话 ID
}
// PluginAPIRequest 插件 API 请求
type PluginAPIRequest struct {
PluginID string `json:"plugin_id"` // 插件实例唯一 ID
PluginName string `json:"plugin_name"` // 插件名称 (向后兼容)
Method string `json:"method"` // HTTP 方法: GET, POST, PUT, DELETE
Path string `json:"path"` // 路由路径
Query string `json:"query"` // 查询参数
Headers map[string]string `json:"headers"` // 请求头
Body string `json:"body"` // 请求体
}
// PluginAPIResponse 插件 API 响应
type PluginAPIResponse struct {
Status int `json:"status"` // HTTP 状态码
Headers map[string]string `json:"headers"` // 响应头
Body string `json:"body"` // 响应体
Error string `json:"error"` // 错误信息
}
// WriteMessage 写入消息到 writer
func WriteMessage(w io.Writer, msg *Message) error {
header := make([]byte, HeaderSize)

253
pkg/update/update.go Normal file
View File

@@ -0,0 +1,253 @@
package update
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
// GetArchiveExt 根据 URL 获取压缩包扩展名
func GetArchiveExt(url string) string {
if strings.HasSuffix(url, ".tar.gz") {
return ".tar.gz"
}
if strings.HasSuffix(url, ".zip") {
return ".zip"
}
// 默认根据平台
if runtime.GOOS == "windows" {
return ".zip"
}
return ".tar.gz"
}
// ExtractArchive 解压压缩包
func ExtractArchive(archivePath, destDir string) error {
if strings.HasSuffix(archivePath, ".tar.gz") {
return ExtractTarGz(archivePath, destDir)
}
if strings.HasSuffix(archivePath, ".zip") {
return ExtractZip(archivePath, destDir)
}
return fmt.Errorf("unsupported archive format")
}
// ExtractTarGz 解压 tar.gz 文件
func ExtractTarGz(archivePath, destDir string) error {
file, err := os.Open(archivePath)
if err != nil {
return err
}
defer file.Close()
gzReader, err := gzip.NewReader(file)
if err != nil {
return err
}
defer gzReader.Close()
tarReader := tar.NewReader(gzReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
targetPath := filepath.Join(destDir, header.Name)
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(targetPath, 0755); err != nil {
return err
}
case tar.TypeReg:
outFile, err := os.Create(targetPath)
if err != nil {
return err
}
if _, err := io.Copy(outFile, tarReader); err != nil {
outFile.Close()
return err
}
outFile.Close()
}
}
return nil
}
// ExtractZip 解压 zip 文件
func ExtractZip(archivePath, destDir string) error {
reader, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer reader.Close()
for _, file := range reader.File {
targetPath := filepath.Join(destDir, file.Name)
if file.FileInfo().IsDir() {
if err := os.MkdirAll(targetPath, 0755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
srcFile, err := file.Open()
if err != nil {
return err
}
dstFile, err := os.Create(targetPath)
if err != nil {
srcFile.Close()
return err
}
_, err = io.Copy(dstFile, srcFile)
srcFile.Close()
dstFile.Close()
if err != nil {
return err
}
}
return nil
}
// FindExtractedBinary 在解压目录中查找可执行文件
func FindExtractedBinary(extractDir, component string) (string, error) {
var binaryPath string
prefix := "gotunnel-" + component
err := filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
name := info.Name()
// 匹配 gotunnel-server-* 或 gotunnel-client-*
if strings.HasPrefix(name, prefix) {
// 排除压缩包本身
if !strings.HasSuffix(name, ".tar.gz") && !strings.HasSuffix(name, ".zip") {
binaryPath = path
return filepath.SkipAll
}
}
return nil
})
if err != nil && err != filepath.SkipAll {
return "", err
}
if binaryPath == "" {
return "", fmt.Errorf("binary not found in archive")
}
return binaryPath, nil
}
// CopyFile 复制文件
func CopyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}
// DownloadFile 下载文件
func DownloadFile(url, dest string) error {
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: %s", resp.Status)
}
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// DownloadAndExtract 下载并解压压缩包,返回解压后的可执行文件路径
func DownloadAndExtract(downloadURL, component string) (binaryPath string, cleanup func(), err error) {
tempDir := os.TempDir()
timestamp := time.Now().Format("20060102150405")
archivePath := filepath.Join(tempDir, "gotunnel_update_"+timestamp+GetArchiveExt(downloadURL))
if err := DownloadFile(downloadURL, archivePath); err != nil {
return "", nil, fmt.Errorf("download update: %w", err)
}
extractDir := filepath.Join(tempDir, "gotunnel_extract_"+timestamp)
if err := os.MkdirAll(extractDir, 0755); err != nil {
os.Remove(archivePath)
return "", nil, fmt.Errorf("create extract dir: %w", err)
}
cleanup = func() {
os.Remove(archivePath)
os.RemoveAll(extractDir)
}
if err := ExtractArchive(archivePath, extractDir); err != nil {
cleanup()
return "", nil, fmt.Errorf("extract archive: %w", err)
}
binaryPath, err = FindExtractedBinary(extractDir, component)
if err != nil {
cleanup()
return "", nil, fmt.Errorf("find binary: %w", err)
}
// 设置执行权限
if runtime.GOOS != "windows" {
if err := os.Chmod(binaryPath, 0755); err != nil {
cleanup()
return "", nil, fmt.Errorf("chmod: %w", err)
}
}
return binaryPath, cleanup, nil
}

230
pkg/version/version.go Normal file
View File

@@ -0,0 +1,230 @@
package version
import (
"encoding/json"
"fmt"
"io"
"net/http"
"runtime"
"strconv"
"strings"
"time"
)
// 版本信息
const Version = "1.0.0"
// 仓库信息
const (
RepoURL = "https://git.92coco.cn:8443/flik/GoTunnel"
APIBaseURL = "https://git.92coco.cn:8443/api/v1"
RepoOwner = "flik"
RepoName = "GoTunnel"
)
// Info 版本详细信息
type Info struct {
Version string `json:"version"`
GitCommit string `json:"git_commit"`
BuildTime string `json:"build_time"`
GoVersion string `json:"go_version"`
OS string `json:"os"`
Arch string `json:"arch"`
}
// GetInfo 获取版本信息
func GetInfo() Info {
return Info{
Version: Version,
GitCommit: "",
BuildTime: "",
GoVersion: runtime.Version(),
OS: runtime.GOOS,
Arch: runtime.GOARCH,
}
}
// ReleaseInfo Release 信息
type ReleaseInfo struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
PublishedAt string `json:"published_at"`
Assets []ReleaseAsset `json:"assets"`
}
// ReleaseAsset Release 资产
type ReleaseAsset struct {
Name string `json:"name"`
Size int64 `json:"size"`
BrowserDownloadURL string `json:"browser_download_url"`
}
// UpdateInfo 更新信息
type UpdateInfo struct {
Latest string `json:"latest"`
ReleaseNote string `json:"release_note"`
DownloadURL string `json:"download_url"`
AssetName string `json:"asset_name"`
AssetSize int64 `json:"asset_size"`
}
// GetLatestRelease 获取最新 Release
// Gitea 兼容:先尝试 /releases/latest失败则尝试 /releases 取第一个
func GetLatestRelease() (*ReleaseInfo, error) {
client := &http.Client{Timeout: 30 * time.Second}
// 首先尝试 /releases/latest 端点GitHub 兼容)
latestURL := fmt.Sprintf("%s/repos/%s/%s/releases/latest", APIBaseURL, RepoOwner, RepoName)
resp, err := client.Get(latestURL)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
var release ReleaseInfo
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &release, nil
}
// 如果 /releases/latest 不可用,尝试 /releases 并取第一个
resp.Body.Close()
listURL := fmt.Sprintf("%s/repos/%s/%s/releases?limit=1", APIBaseURL, RepoOwner, RepoName)
resp, err = client.Get(listURL)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error: %s - %s", resp.Status, string(body))
}
var releases []ReleaseInfo
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if len(releases) == 0 {
return nil, fmt.Errorf("no releases found in repository")
}
return &releases[0], nil
}
// CheckUpdate 检查更新(返回最新版本信息)
func CheckUpdate(component string) (*UpdateInfo, error) {
release, err := GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
// 查找对应平台的资产
var downloadURL string
var assetName string
var assetSize int64
if asset := findAssetForPlatform(release.Assets, component, runtime.GOOS, runtime.GOARCH); asset != nil {
downloadURL = asset.BrowserDownloadURL
assetName = asset.Name
assetSize = asset.Size
}
return &UpdateInfo{
Latest: release.TagName,
ReleaseNote: release.Body,
DownloadURL: downloadURL,
AssetName: assetName,
AssetSize: assetSize,
}, nil
}
// CheckUpdateForPlatform 检查指定平台的更新
func CheckUpdateForPlatform(component, osName, arch string) (*UpdateInfo, error) {
release, err := GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
// 查找对应平台的资产
var downloadURL string
var assetName string
var assetSize int64
if asset := findAssetForPlatform(release.Assets, component, osName, arch); asset != nil {
downloadURL = asset.BrowserDownloadURL
assetName = asset.Name
assetSize = asset.Size
}
return &UpdateInfo{
Latest: release.TagName,
ReleaseNote: release.Body,
DownloadURL: downloadURL,
AssetName: assetName,
AssetSize: assetSize,
}, nil
}
// findAssetForPlatform 在 Release 资产中查找匹配的文件
func findAssetForPlatform(assets []ReleaseAsset, component, osName, arch string) *ReleaseAsset {
// 构建匹配模式
// CI 格式: gotunnel-server-v1.0.0-linux-amd64.tar.gz
// 或者: gotunnel-client-v1.0.0-windows-amd64.zip
prefix := fmt.Sprintf("gotunnel-%s-", component)
suffix := fmt.Sprintf("-%s-%s", osName, arch)
for i := range assets {
name := assets[i].Name
// 检查是否匹配 gotunnel-{component}-{version}-{os}-{arch}.{ext}
if strings.HasPrefix(name, prefix) && strings.Contains(name, suffix) {
return &assets[i]
}
}
return nil
}
// CompareVersions 比较版本号
// 返回: -1 (v1 < v2), 0 (v1 == v2), 1 (v1 > v2)
func CompareVersions(v1, v2 string) int {
parts1 := parseVersionParts(v1)
parts2 := parseVersionParts(v2)
maxLen := len(parts1)
if len(parts2) > maxLen {
maxLen = len(parts2)
}
for i := 0; i < maxLen; i++ {
var p1, p2 int
if i < len(parts1) {
p1 = parts1[i]
}
if i < len(parts2) {
p2 = parts2[i]
}
if p1 < p2 {
return -1
}
if p1 > p2 {
return 1
}
}
return 0
}
func parseVersionParts(v string) []int {
v = strings.TrimPrefix(v, "v")
parts := strings.Split(v, ".")
result := make([]int, len(parts))
for i, p := range parts {
n, _ := strconv.Atoi(p)
result[i] = n
}
return result
}

426
plan.md
View File

@@ -1,426 +0,0 @@
# GoTunnel 架构修复计划
> 面向 100 万用户发布前的安全与稳定性修复方案
## 问题概览
| 严重程度 | 数量 | 状态 |
|---------|------|------|
| P0 严重 | 5 | ✅ 已修复 |
| P1 高 | 5 | ✅ 已修复 |
| P2 中 | 13 | 计划中 |
| P3 低 | 15 | 后续迭代 |
---
## 修复完成总结
### P0 严重问题 (已全部修复)
| 编号 | 问题 | 修复文件 | 状态 |
|-----|------|---------|------|
| 1.1 | TLS 证书验证 | `pkg/crypto/tls.go` | ✅ TOFU 机制 |
| 1.2 | Web 控制台无认证 | `cmd/server/main.go`, `config/config.go` | ✅ 强制认证 |
| 1.3 | 认证检查端点失效 | `router/auth.go` | ✅ 实际验证 JWT |
| 1.4 | Token 生成错误 | `config/config.go` | ✅ 错误检查 |
| 1.5 | 客户端 ID 未验证 | `tunnel/server.go` | ✅ 正则验证 |
### P1 高优先级问题 (已全部修复)
| 编号 | 问题 | 修复文件 | 状态 |
|-----|------|---------|------|
| 2.1 | 无连接数限制 | `tunnel/server.go` | ✅ 10000 上限 |
| 2.3 | 无优雅关闭 | `tunnel/server.go`, `cmd/server/main.go` | ✅ 信号处理 |
| 2.4 | 消息大小未验证 | `protocol/message.go` | ✅ 已有验证 |
| 2.5 | 无安全事件日志 | `pkg/security/audit.go` | ✅ 新增模块 |
---
## 第一阶段P0 严重问题 (发布前必须修复)
### 1.1 TLS 证书验证被禁用
**文件**: `pkg/crypto/tls.go`
**问题**: `InsecureSkipVerify: true` 导致中间人攻击风险
**修复方案**:
- 添加服务端证书指纹验证机制
- 客户端首次连接时保存服务端证书指纹
- 后续连接验证指纹是否匹配Trust On First Use
- 提供 `--skip-verify` 参数供测试环境使用
**修改内容**:
```go
// pkg/crypto/tls.go
func ClientTLSConfig(serverFingerprint string) *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true, // 仍需要,因为是自签名证书
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
// 验证证书指纹
return verifyCertFingerprint(rawCerts, serverFingerprint)
},
}
}
```
---
### 1.2 Web 控制台无认证
**文件**: `cmd/server/main.go`
**问题**: 默认配置下 Web 控制台完全开放
**修复方案**:
- 首次启动时自动生成随机密码
- 强制要求配置用户名密码
- 无认证时拒绝启动 Web 服务
**修改内容**:
```go
// cmd/server/main.go
if cfg.Web.Enabled {
if cfg.Web.Username == "" || cfg.Web.Password == "" {
// 自动生成凭据
cfg.Web.Username = "admin"
cfg.Web.Password = generateSecurePassword(16)
log.Printf("[Web] 自动生成凭据 - 用户名: %s, 密码: %s",
cfg.Web.Username, cfg.Web.Password)
// 保存到配置文件
saveConfig(cfg)
}
}
```
---
### 1.3 认证检查端点失效
**文件**: `internal/server/router/auth.go`
**问题**: `/auth/check` 始终返回 `valid: true`
**修复方案**:
- 实际验证 JWT Token
- 返回真实的验证结果
**修改内容**:
```go
// internal/server/router/auth.go
func (h *AuthHandler) handleCheck(w http.ResponseWriter, r *http.Request) {
// 从 Authorization header 获取 token
token := extractToken(r)
if token == "" {
jsonError(w, "missing token", http.StatusUnauthorized)
return
}
// 验证 token
claims, err := h.validateToken(token)
if err != nil {
jsonError(w, "invalid token", http.StatusUnauthorized)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"valid": true,
"user": claims.Username,
})
}
```
---
### 1.4 Token 生成错误未处理
**文件**: `internal/server/config/config.go`
**问题**: `rand.Read()` 错误被忽略,可能生成弱 Token
**修复方案**:
- 检查 `rand.Read()` 返回值
- 失败时 panic 或返回错误
- 增加 Token 强度验证
**修改内容**:
```go
// internal/server/config/config.go
func generateToken(length int) (string, error) {
bytes := make([]byte, length/2)
n, err := rand.Read(bytes)
if err != nil {
return "", fmt.Errorf("failed to generate token: %w", err)
}
if n != len(bytes) {
return "", fmt.Errorf("insufficient random bytes: got %d, want %d", n, len(bytes))
}
return hex.EncodeToString(bytes), nil
}
```
---
### 1.5 客户端 ID 未验证
**文件**: `internal/server/tunnel/server.go`
**问题**: tunnel server 中未使用已有的 ID 验证函数
**修复方案**:
- 在 handleConnection 中验证 clientID
- 拒绝非法格式的 ID
- 记录安全日志
**修改内容**:
```go
// internal/server/tunnel/server.go
func (s *Server) handleConnection(conn net.Conn) {
// ... 读取认证消息后
clientID := authReq.ClientID
if clientID != "" && !isValidClientID(clientID) {
log.Printf("[Security] Invalid client ID format from %s: %s",
conn.RemoteAddr(), clientID)
sendAuthResponse(conn, false, "invalid client id format")
return
}
// ...
}
var clientIDRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
func isValidClientID(id string) bool {
return clientIDRegex.MatchString(id)
}
```
---
## 第二阶段P1 高优先级问题 (发布前建议修复)
### 2.1 无连接数限制
**文件**: `internal/server/tunnel/server.go`
**修复方案**:
- 添加全局最大连接数限制
- 添加单客户端连接数限制
- 使用 semaphore 控制并发
**修改内容**:
```go
type Server struct {
// ...
maxConns int
connSem chan struct{} // semaphore
clientConns map[string]int
}
func (s *Server) handleConnection(conn net.Conn) {
select {
case s.connSem <- struct{}{}:
defer func() { <-s.connSem }()
default:
conn.Close()
log.Printf("[Server] Connection rejected: max connections reached")
return
}
// ...
}
```
---
### 2.2 Goroutine 泄漏
**文件**: 多个文件
**修复方案**:
- 使用 context 控制 goroutine 生命周期
- 添加 goroutine 池
- 确保所有 goroutine 有退出机制
---
### 2.3 无优雅关闭
**文件**: `cmd/server/main.go`
**修复方案**:
- 监听 SIGTERM/SIGINT 信号
- 关闭所有监听器
- 等待现有连接完成
- 设置关闭超时
**修改内容**:
```go
// cmd/server/main.go
func main() {
// ...
// 优雅关闭
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-quit
log.Println("[Server] Shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.Shutdown(ctx)
webServer.Shutdown(ctx)
}()
server.Run()
}
```
---
### 2.4 消息大小未验证
**文件**: `pkg/protocol/message.go`
**修复方案**:
- 在 ReadMessage 中检查消息长度
- 超过限制时返回错误
---
### 2.5 无读写超时
**文件**: `internal/server/tunnel/server.go`
**修复方案**:
- 所有连接设置读写超时
- 使用 SetDeadline 而非一次性设置
---
### 2.6 竞态条件
**文件**: `internal/server/tunnel/server.go`
**修复方案**:
- 使用 sync.Map 替代 map + mutex
- 或确保所有 map 访问都在锁保护下
---
### 2.7 无安全事件日志
**修复方案**:
- 添加安全日志模块
- 记录认证失败、异常访问等事件
- 支持日志轮转
---
## 第三阶段P2 中优先级问题 (发布后迭代)
| 编号 | 问题 | 文件 |
|-----|------|------|
| 3.1 | 配置文件权限过宽 (0644) | config/config.go |
| 3.2 | 心跳机制不完善 | tunnel/server.go |
| 3.3 | HTTP 代理无 SSRF 防护 | proxy/http.go |
| 3.4 | SOCKS5 代理无验证 | proxy/socks5.go |
| 3.5 | 数据库操作无超时 | db/sqlite.go |
| 3.6 | 错误处理不一致 | 多个文件 |
| 3.7 | UDP 缓冲区无限制 | tunnel/server.go |
| 3.8 | 代理规则无验证 | tunnel/server.go |
| 3.9 | 客户端注册竞态 | tunnel/server.go |
| 3.10 | Relay 资源泄漏 | relay/relay.go |
| 3.11 | 插件配置无验证 | tunnel/server.go |
| 3.12 | 端口号无边界检查 | tunnel/server.go |
| 3.13 | 插件商店 URL 硬编码 | config/config.go |
---
## 第四阶段P3 低优先级问题 (后续优化)
| 编号 | 问题 | 建议 |
|-----|------|------|
| 4.1 | 无结构化日志 | 引入 zap/zerolog |
| 4.2 | 无连接池 | 实现连接池 |
| 4.3 | 线性查找规则 | 使用 map 索引 |
| 4.4 | 无数据库缓存 | 添加内存缓存 |
| 4.5 | 魔法数字 | 提取为常量 |
| 4.6 | 无 godoc 注释 | 补充文档 |
| 4.7 | 配置无验证 | 添加验证逻辑 |
---
## 修复顺序
```
Week 1: P0 问题 (5个)
├── Day 1-2: 1.1 TLS 证书验证
├── Day 2-3: 1.2 Web 控制台认证
├── Day 3-4: 1.3 认证检查端点
├── Day 4: 1.4 Token 生成
└── Day 5: 1.5 客户端 ID 验证
Week 2: P1 问题 (7个)
├── Day 1-2: 2.1 连接数限制
├── Day 2-3: 2.2 Goroutine 泄漏
├── Day 3-4: 2.3 优雅关闭
├── Day 4: 2.4 消息大小验证
├── Day 5: 2.5 读写超时
└── Day 5: 2.6-2.7 竞态条件 + 安全日志
Week 3+: P2/P3 问题
└── 按优先级逐步修复
```
---
## 测试计划
### 安全测试
- [ ] TLS 中间人攻击测试
- [ ] 认证绕过测试
- [ ] 注入攻击测试
- [ ] DoS 攻击测试
### 稳定性测试
- [ ] 长时间运行测试 (72h+)
- [ ] 高并发连接测试 (10000+)
- [ ] 内存泄漏测试
- [ ] Goroutine 泄漏测试
### 性能测试
- [ ] 吞吐量基准测试
- [ ] 延迟基准测试
- [ ] 资源使用监控
---
## 回滚方案
如发布后发现严重问题:
1. **立即回滚**: 保留上一版本二进制文件
2. **热修复**: 针对特定问题发布补丁
3. **降级运行**: 禁用问题功能模块
---
## 监控告警
发布后需要监控的指标:
- 连接数 / 活跃客户端数
- 内存使用 / Goroutine 数量
- 认证失败率
- 错误日志频率
- 响应延迟 P99
---
*文档版本: 1.0*
*创建时间: 2025-12-29*
*状态: 待审核*

View File

@@ -1,30 +0,0 @@
// Echo JS Plugin - 回显插件示例
function metadata() {
return {
name: "echo-js",
version: "1.0.0",
type: "app",
run_at: "client",
description: "Echo plugin written in JavaScript",
author: "GoTunnel"
};
}
function start() {
log("Echo JS plugin started");
}
function handleConn(conn) {
log("New connection");
while (true) {
var data = conn.Read(4096);
if (!data || data.length === 0) {
break;
}
conn.Write(data);
}
}
function stop() {
log("Echo JS plugin stopped");
}

View File

@@ -1,194 +0,0 @@
// FileManager JS Plugin - 文件管理插件
// 提供 HTTP API 管理客户端本地文件
var authToken = "";
var basePath = "/";
function metadata() {
return {
name: "filemanager",
version: "1.0.0",
type: "app",
run_at: "client",
description: "File manager with HTTP API",
author: "GoTunnel"
};
}
function start() {
authToken = config("auth_token") || "admin";
basePath = config("base_path") || "/";
log("FileManager started, base: " + basePath);
}
function stop() {
log("FileManager stopped");
}
// 处理连接
function handleConn(conn) {
var data = conn.Read(4096);
if (!data) return;
var req = parseRequest(String.fromCharCode.apply(null, data));
var resp = handleRequest(req);
conn.Write(stringToBytes(resp));
}
// 解析 HTTP 请求
function parseRequest(raw) {
var lines = raw.split("\r\n");
var first = lines[0].split(" ");
var req = {
method: first[0] || "GET",
path: first[1] || "/",
headers: {},
body: ""
};
var bodyStart = raw.indexOf("\r\n\r\n");
if (bodyStart > 0) {
req.body = raw.substring(bodyStart + 4);
}
return req;
}
// 处理请求
function handleRequest(req) {
// 检查认证
if (req.path.indexOf("?token=" + authToken) < 0) {
return httpResponse(401, {error: "Unauthorized"});
}
var path = req.path.split("?")[0];
if (path === "/api/list") {
return handleList(req);
} else if (path === "/api/read") {
return handleRead(req);
} else if (path === "/api/write") {
return handleWrite(req);
} else if (path === "/api/delete") {
return handleDelete(req);
}
return httpResponse(404, {error: "Not found"});
}
// 获取查询参数
function getQueryParam(req, name) {
var query = req.path.split("?")[1] || "";
var params = query.split("&");
for (var i = 0; i < params.length; i++) {
var pair = params[i].split("=");
if (pair[0] === name) {
return decodeURIComponent(pair[1] || "");
}
}
return "";
}
// 安全路径检查
function safePath(path) {
if (!path) return basePath;
// 防止路径遍历
if (path.indexOf("..") >= 0) return null;
if (path.charAt(0) !== "/") {
path = basePath + "/" + path;
}
return path;
}
// 列出目录
function handleList(req) {
var dir = safePath(getQueryParam(req, "path"));
if (!dir) {
return httpResponse(400, {error: "Invalid path"});
}
var entries = fs.readDir(dir);
if (!entries) {
return httpResponse(404, {error: "Directory not found"});
}
return httpResponse(200, {path: dir, entries: entries});
}
// 读取文件
function handleRead(req) {
var file = safePath(getQueryParam(req, "path"));
if (!file) {
return httpResponse(400, {error: "Invalid path"});
}
var stat = fs.stat(file);
if (!stat) {
return httpResponse(404, {error: "File not found"});
}
if (stat.isDir) {
return httpResponse(400, {error: "Cannot read directory"});
}
var content = fs.readFile(file);
return httpResponse(200, {path: file, content: content, size: stat.size});
}
// 写入文件
function handleWrite(req) {
var file = safePath(getQueryParam(req, "path"));
if (!file) {
return httpResponse(400, {error: "Invalid path"});
}
if (req.method !== "POST") {
return httpResponse(405, {error: "Method not allowed"});
}
if (fs.writeFile(file, req.body)) {
return httpResponse(200, {success: true, path: file});
}
return httpResponse(500, {error: "Write failed"});
}
// 删除文件
function handleDelete(req) {
var file = safePath(getQueryParam(req, "path"));
if (!file) {
return httpResponse(400, {error: "Invalid path"});
}
if (!fs.exists(file)) {
return httpResponse(404, {error: "File not found"});
}
if (fs.remove(file)) {
return httpResponse(200, {success: true, path: file});
}
return httpResponse(500, {error: "Delete failed"});
}
// 构建 HTTP 响应
function httpResponse(status, data) {
var body = JSON.stringify(data);
var statusText = status === 200 ? "OK" :
status === 400 ? "Bad Request" :
status === 401 ? "Unauthorized" :
status === 404 ? "Not Found" :
status === 405 ? "Method Not Allowed" :
status === 500 ? "Internal Server Error" : "Unknown";
return "HTTP/1.1 " + status + " " + statusText + "\r\n" +
"Content-Type: application/json\r\n" +
"Content-Length: " + body.length + "\r\n" +
"Access-Control-Allow-Origin: *\r\n" +
"\r\n" + body;
}
// 字符串转字节数组
function stringToBytes(str) {
var bytes = [];
for (var i = 0; i < str.length; i++) {
bytes.push(str.charCodeAt(i));
}
return bytes;
}

19
scripts/build.bat Normal file
View File

@@ -0,0 +1,19 @@
@echo off
REM GoTunnel Build Script Launcher for Windows
REM This script launches the PowerShell build script
setlocal
set SCRIPT_DIR=%~dp0
REM Check if PowerShell is available
where powershell >nul 2>nul
if %ERRORLEVEL% neq 0 (
echo ERROR: PowerShell is not available
exit /b 1
)
REM Pass all arguments to PowerShell script
powershell -ExecutionPolicy Bypass -File "%SCRIPT_DIR%build.ps1" %*
endlocal

295
scripts/build.ps1 Normal file
View File

@@ -0,0 +1,295 @@
# GoTunnel Build Script for Windows
# Usage: .\build.ps1 [command]
# Commands: all, current, web, server, client, clean, help
param(
[Parameter(Position=0)]
[string]$Command = "all",
[string]$Version = "dev",
[switch]$NoUPX
)
$ErrorActionPreference = "Stop"
# 项目根目录
$RootDir = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
if (-not $RootDir) {
$RootDir = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
}
$BuildDir = Join-Path $RootDir "build"
# 版本信息
$BuildTime = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
try {
$GitCommit = (git -C $RootDir rev-parse --short HEAD 2>$null)
if (-not $GitCommit) { $GitCommit = "unknown" }
} catch {
$GitCommit = "unknown"
}
# 目标平台
$Platforms = @(
@{OS="windows"; Arch="amd64"},
@{OS="linux"; Arch="amd64"},
@{OS="linux"; Arch="arm64"},
@{OS="darwin"; Arch="amd64"},
@{OS="darwin"; Arch="arm64"}
)
)
# 颜色输出函数
function Write-Info {
param([string]$Message)
Write-Host "[INFO] " -ForegroundColor Green -NoNewline
Write-Host $Message
}
function Write-Warn {
param([string]$Message)
Write-Host "[WARN] " -ForegroundColor Yellow -NoNewline
Write-Host $Message
}
function Write-Err {
param([string]$Message)
Write-Host "[ERROR] " -ForegroundColor Red -NoNewline
Write-Host $Message
}
# 检查 UPX 是否可用
function Test-UPX {
try {
$null = Get-Command upx -ErrorAction Stop
return $true
} catch {
return $false
}
}
# UPX 压缩二进制
function Compress-Binary {
param([string]$FilePath, [string]$OS)
if ($NoUPX) { return }
if (-not (Test-UPX)) {
Write-Warn "UPX not found, skipping compression"
return
}
# macOS 二进制不支持 UPX
if ($OS -eq "darwin") {
Write-Warn "Skipping UPX for macOS binary: $FilePath"
return
}
Write-Info "Compressing $FilePath with UPX..."
try {
& upx -9 -q $FilePath 2>$null
} catch {
Write-Warn "UPX compression failed for $FilePath"
}
}
# 构建 Web UI
function Build-Web {
Write-Info "Building web UI..."
$WebDir = Join-Path $RootDir "web"
Push-Location $WebDir
try {
if (-not (Test-Path "node_modules")) {
Write-Info "Installing npm dependencies..."
& npm install
if ($LASTEXITCODE -ne 0) { throw "npm install failed" }
}
& npm run build
if ($LASTEXITCODE -ne 0) { throw "npm build failed" }
} finally {
Pop-Location
}
# 复制到 embed 目录
Write-Info "Copying dist to embed directory..."
$DistSource = Join-Path $WebDir "dist"
$DistDest = Join-Path $RootDir "internal\server\app\dist"
if (Test-Path $DistDest) {
Remove-Item -Recurse -Force $DistDest
}
Copy-Item -Recurse $DistSource $DistDest
Write-Info "Web UI built successfully"
}
# 构建单个二进制
function Build-Binary {
param(
[string]$OS,
[string]$Arch,
[string]$Component # server 或 client
)
$OutputName = $Component
if ($OS -eq "windows") {
$OutputName = "$Component.exe"
}
$OutputDir = Join-Path $BuildDir "${OS}_${Arch}"
if (-not (Test-Path $OutputDir)) {
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
}
Write-Info "Building $Component for $OS/$Arch..."
$env:GOOS = $OS
$env:GOARCH = $Arch
$env:CGO_ENABLED = "0"
$LDFlags = "-s -w -X 'github.com/gotunnel/pkg/version.Version=$Version' -X 'github.com/gotunnel/pkg/version.BuildTime=$BuildTime' -X 'github.com/gotunnel/pkg/version.GitCommit=$GitCommit'"
$OutputPath = Join-Path $OutputDir $OutputName
$SourcePath = Join-Path $RootDir "cmd\$Component"
& go build -ldflags $LDFlags -o $OutputPath $SourcePath
if ($LASTEXITCODE -ne 0) {
throw "Build failed for $Component $OS/$Arch"
}
# UPX 压缩
Compress-Binary -FilePath $OutputPath -OS $OS
# 显示文件大小
$FileSize = (Get-Item $OutputPath).Length / 1MB
Write-Info " -> $OutputPath ({0:N2} MB)" -f $FileSize
}
# 构建所有平台
function Build-All {
foreach ($Platform in $Platforms) {
Build-Binary -OS $Platform.OS -Arch $Platform.Arch -Component "server"
Build-Binary -OS $Platform.OS -Arch $Platform.Arch -Component "client"
}
Write-Info ""
Write-Info "Build completed! Output directory: $BuildDir"
Write-Info ""
Write-Info "Built files:"
Get-ChildItem -Recurse $BuildDir -File | ForEach-Object {
$RelPath = $_.FullName.Replace($BuildDir, "").TrimStart("\")
$Size = "{0:N2} MB" -f ($_.Length / 1MB)
Write-Host " $RelPath ($Size)"
}
}
# 仅构建当前平台
function Build-Current {
$OS = go env GOOS
$Arch = go env GOARCH
Build-Binary -OS $OS -Arch $Arch -Component "server"
Build-Binary -OS $OS -Arch $Arch -Component "client"
Write-Info "Binaries built in $BuildDir\${OS}_${Arch}\"
}
# 清理构建产物
function Clean-Build {
Write-Info "Cleaning build directory..."
if (Test-Path $BuildDir) {
Remove-Item -Recurse -Force $BuildDir
}
Write-Info "Clean completed"
}
# 显示帮助
function Show-Help {
Write-Host @"
GoTunnel Build Script for Windows
Usage: .\build.ps1 [command] [-Version <version>] [-NoUPX]
Commands:
all Build web UI + all platforms (default)
current Build web UI + current platform only
web Build web UI only
server Build server for current platform
client Build client for current platform
clean Clean build directory
help Show this help message
Options:
-Version Set version string (default: dev)
-NoUPX Disable UPX compression
Target platforms:
- windows/amd64
- linux/amd64
- linux/arm64
- darwin/amd64 (macOS Intel)
- darwin/arm64 (macOS Apple Silicon)
Examples:
.\build.ps1 # Build all platforms
.\build.ps1 all -Version 1.0.0 # Build with version
.\build.ps1 current # Build current platform only
.\build.ps1 clean # Clean build directory
"@
}
# 主函数
function Main {
Push-Location $RootDir
try {
Write-Info "GoTunnel Build Script"
Write-Info "Version: $Version | Commit: $GitCommit"
Write-Info ""
switch ($Command.ToLower()) {
"all" {
Build-Web
Build-All
}
"current" {
Build-Web
Build-Current
}
"web" {
Build-Web
}
"server" {
$OS = go env GOOS
$Arch = go env GOARCH
Build-Binary -OS $OS -Arch $Arch -Component "server"
}
"client" {
$OS = go env GOOS
$Arch = go env GOARCH
Build-Binary -OS $OS -Arch $Arch -Component "client"
}
"clean" {
Clean-Build
}
{ $_ -in "help", "--help", "-h", "/?" } {
Show-Help
return
}
default {
Write-Err "Unknown command: $Command"
Show-Help
exit 1
}
}
Write-Info ""
Write-Info "Done!"
} finally {
Pop-Location
}
}
Main

1
web/components.d.ts vendored
View File

@@ -12,6 +12,7 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
LogViewer: typeof import('./src/components/LogViewer.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}

6
web/package-lock.json generated
View File

@@ -941,7 +941,6 @@
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1235,7 +1234,6 @@
"resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz",
"integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@emotion/hash": "~0.8.0",
"csstype": "~3.0.5"
@@ -1258,7 +1256,6 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@@ -2006,7 +2003,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2185,7 +2181,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -2279,7 +2274,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-sfc": "3.5.26",

View File

@@ -1,7 +1,7 @@
{
"name": "webui",
"name": "GoTunnel",
"private": true,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,5 @@
import { get, post, put, del } from '../config/axios'
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo, PluginConfigResponse, JSPlugin } from '../types'
import { get, post, put, del, getToken } from '../config/axios'
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo, PluginConfigResponse, JSPlugin, RuleSchemasMap, LogEntry, LogStreamOptions, ConfigField } from '../types'
// 重新导出 token 管理方法
export { getToken, setToken, removeToken } from '../config/axios'
@@ -23,9 +23,25 @@ export const reloadConfig = () => post('/config/reload')
// 客户端控制
export const pushConfigToClient = (id: string) => post(`/client/${id}/push`)
export const disconnectClient = (id: string) => post(`/client/${id}/disconnect`)
export const restartClient = (id: string) => post(`/client/${id}/restart`)
export const installPluginsToClient = (id: string, plugins: string[]) =>
post(`/client/${id}/install-plugins`, { plugins })
// 规则配置模式
export const getRuleSchemas = () => get<RuleSchemasMap>('/rule-schemas')
// 客户端插件控制(使用 pluginID
export const startClientPlugin = (clientId: string, pluginId: string, ruleName: string) =>
post(`/client/${clientId}/plugin/${pluginId}/start`, { rule_name: ruleName })
export const stopClientPlugin = (clientId: string, pluginId: string, ruleName: string) =>
post(`/client/${clientId}/plugin/${pluginId}/stop`, { rule_name: ruleName })
export const restartClientPlugin = (clientId: string, pluginId: string, ruleName: string) =>
post(`/client/${clientId}/plugin/${pluginId}/restart`, { rule_name: ruleName })
export const deleteClientPlugin = (clientId: string, pluginId: string) =>
post(`/client/${clientId}/plugin/${pluginId}/delete`)
export const updateClientPluginConfigWithRestart = (clientId: string, pluginId: string, ruleName: string, config: Record<string, string>, restart: boolean) =>
post(`/client/${clientId}/plugin/${pluginId}/config`, { rule_name: ruleName, config, restart })
// 插件管理
export const getPlugins = () => get<PluginInfo[]>('/plugins')
export const enablePlugin = (name: string) => post(`/plugin/${name}/enable`)
@@ -33,8 +49,30 @@ export const disablePlugin = (name: string) => post(`/plugin/${name}/disable`)
// 扩展商店
export const getStorePlugins = () => get<{ plugins: StorePluginInfo[] }>('/store/plugins')
export const installStorePlugin = (pluginName: string, downloadUrl: string, signatureUrl: string, clientId: string) =>
post('/store/install', { plugin_name: pluginName, download_url: downloadUrl, signature_url: signatureUrl, client_id: clientId })
export const installStorePlugin = (
pluginName: string,
downloadUrl: string,
signatureUrl: string,
clientId: string,
remotePort?: number,
version?: string,
configSchema?: ConfigField[],
authEnabled?: boolean,
authUsername?: string,
authPassword?: string
) =>
post('/store/install', {
plugin_name: pluginName,
version: version || '',
download_url: downloadUrl,
signature_url: signatureUrl,
client_id: clientId,
remote_port: remotePort || 0,
config_schema: configSchema || [],
auth_enabled: authEnabled || false,
auth_username: authUsername || '',
auth_password: authPassword || ''
})
// 客户端插件配置
export const getClientPluginConfig = (clientId: string, pluginName: string) =>
@@ -48,5 +86,97 @@ export const createJSPlugin = (plugin: JSPlugin) => post('/js-plugins', plugin)
export const getJSPlugin = (name: string) => get<JSPlugin>(`/js-plugin/${name}`)
export const updateJSPlugin = (name: string, plugin: JSPlugin) => put(`/js-plugin/${name}`, plugin)
export const deleteJSPlugin = (name: string) => del(`/js-plugin/${name}`)
export const pushJSPluginToClient = (pluginName: string, clientId: string) =>
post(`/js-plugin/${pluginName}/push/${clientId}`)
export const pushJSPluginToClient = (pluginName: string, clientId: string, remotePort?: number) =>
post(`/js-plugin/${pluginName}/push/${clientId}`, { remote_port: remotePort || 0 })
export const updateJSPluginConfig = (name: string, config: Record<string, string>) =>
put(`/js-plugin/${name}/config`, { config })
export const setJSPluginEnabled = (name: string, enabled: boolean) =>
post(`/js-plugin/${name}/${enabled ? 'enable' : 'disable'}`)
// 插件 API 代理(通过 pluginID 调用插件自定义 API
export const callPluginAPI = <T = any>(clientId: string, pluginId: string, method: string, route: string, body?: any) => {
const path = `/client/${clientId}/plugin-api/${pluginId}${route.startsWith('/') ? route : '/' + route}`
switch (method.toUpperCase()) {
case 'GET':
return get<T>(path)
case 'POST':
return post<T>(path, body)
case 'PUT':
return put<T>(path, body)
case 'DELETE':
return del<T>(path)
default:
return get<T>(path)
}
}
// 更新管理
export interface UpdateInfo {
available: boolean
current: string
latest: string
release_note: string
download_url: string
asset_name: string
asset_size: number
}
export interface VersionInfo {
version: string
git_commit: string
build_time: string
go_version: string
os: string
arch: string
}
export const getVersionInfo = () => get<VersionInfo>('/update/version')
export const checkServerUpdate = () => get<UpdateInfo>('/update/check/server')
export const checkClientUpdate = (os?: string, arch?: string) => {
const params = new URLSearchParams()
if (os) params.append('os', os)
if (arch) params.append('arch', arch)
const query = params.toString()
return get<UpdateInfo>(`/update/check/client${query ? '?' + query : ''}`)
}
export const applyServerUpdate = (downloadUrl: string, restart: boolean = true) =>
post('/update/apply/server', { download_url: downloadUrl, restart })
export const applyClientUpdate = (clientId: string, downloadUrl: string) =>
post('/update/apply/client', { client_id: clientId, download_url: downloadUrl })
// 日志流
export const createLogStream = (
clientId: string,
options: LogStreamOptions = {},
onLog: (entry: LogEntry) => void,
onError?: (error: Event) => void
): EventSource => {
const token = getToken()
const params = new URLSearchParams()
if (token) params.append('token', token)
if (options.lines !== undefined) params.append('lines', String(options.lines))
if (options.follow !== undefined) params.append('follow', String(options.follow))
if (options.level) params.append('level', options.level)
const url = `/api/client/${clientId}/logs?${params.toString()}`
const eventSource = new EventSource(url)
eventSource.addEventListener('log', (event) => {
try {
const entry = JSON.parse((event as MessageEvent).data) as LogEntry
onLog(entry)
} catch (e) {
console.error('Failed to parse log entry', e)
}
})
eventSource.addEventListener('heartbeat', () => {
// Keep-alive, no action needed
})
if (onError) {
eventSource.onerror = onError
}
return eventSource
}

View File

@@ -0,0 +1,232 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { NCard, NSpace, NButton, NSelect, NSwitch, NInput, NIcon, NEmpty, NSpin } from 'naive-ui'
import { PlayOutline, StopOutline, TrashOutline, DownloadOutline } from '@vicons/ionicons5'
import { createLogStream } from '../api'
import type { LogEntry } from '../types'
const props = defineProps<{
clientId: string
visible: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const logs = ref<LogEntry[]>([])
const isStreaming = ref(false)
const autoScroll = ref(true)
const levelFilter = ref<string>('')
const searchText = ref('')
const loading = ref(false)
let eventSource: EventSource | null = null
const logContainer = ref<HTMLElement | null>(null)
const levelOptions = [
{ label: '所有级别', value: '' },
{ label: 'Info', value: 'info' },
{ label: 'Warning', value: 'warn' },
{ label: 'Error', value: 'error' },
{ label: 'Debug', value: 'debug' }
]
const startStream = () => {
if (eventSource) {
eventSource.close()
}
loading.value = true
isStreaming.value = true
eventSource = createLogStream(
props.clientId,
{ lines: 500, follow: true, level: levelFilter.value },
(entry) => {
logs.value.push(entry)
// 限制内存中的日志数量
if (logs.value.length > 2000) {
logs.value = logs.value.slice(-1000)
}
if (autoScroll.value) {
nextTick(() => scrollToBottom())
}
loading.value = false
},
() => {
isStreaming.value = false
loading.value = false
}
)
}
const stopStream = () => {
if (eventSource) {
eventSource.close()
eventSource = null
}
isStreaming.value = false
}
const clearLogs = () => {
logs.value = []
}
const scrollToBottom = () => {
if (logContainer.value) {
logContainer.value.scrollTop = logContainer.value.scrollHeight
}
}
const downloadLogs = () => {
const content = logs.value.map(l =>
`${new Date(l.ts).toISOString()} [${l.level.toUpperCase()}] [${l.src}] ${l.msg}`
).join('\n')
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${props.clientId}-logs-${new Date().toISOString().slice(0, 10)}.txt`
a.click()
URL.revokeObjectURL(url)
}
const filteredLogs = computed(() => {
if (!searchText.value) return logs.value
const search = searchText.value.toLowerCase()
return logs.value.filter(l => l.msg.toLowerCase().includes(search))
})
const getLevelColor = (level: string): string => {
switch (level) {
case 'error': return '#e88080'
case 'warn': return '#e8b880'
case 'info': return '#80b8e8'
case 'debug': return '#808080'
default: return '#ffffff'
}
}
const formatTime = (ts: number): string => {
return new Date(ts).toLocaleTimeString('en-US', { hour12: false })
}
watch(() => props.visible, (visible) => {
if (visible) {
startStream()
} else {
stopStream()
}
})
onMounted(() => {
if (props.visible) {
startStream()
}
})
onUnmounted(() => {
stopStream()
})
</script>
<template>
<n-card title="客户端日志" :closable="true" @close="emit('close')">
<template #header-extra>
<n-space :size="8">
<n-select
v-model:value="levelFilter"
:options="levelOptions"
size="small"
style="width: 110px;"
@update:value="() => { stopStream(); logs = []; startStream(); }"
/>
<n-input
v-model:value="searchText"
placeholder="搜索..."
size="small"
style="width: 120px;"
clearable
/>
<n-switch v-model:value="autoScroll" size="small">
<template #checked>自动滚动</template>
<template #unchecked>手动</template>
</n-switch>
<n-button size="small" quaternary @click="clearLogs">
<template #icon><n-icon><TrashOutline /></n-icon></template>
</n-button>
<n-button size="small" quaternary @click="downloadLogs">
<template #icon><n-icon><DownloadOutline /></n-icon></template>
</n-button>
<n-button
size="small"
:type="isStreaming ? 'error' : 'success'"
@click="isStreaming ? stopStream() : startStream()"
>
<template #icon>
<n-icon><StopOutline v-if="isStreaming" /><PlayOutline v-else /></n-icon>
</template>
{{ isStreaming ? '停止' : '开始' }}
</n-button>
</n-space>
</template>
<n-spin :show="loading && logs.length === 0">
<div
ref="logContainer"
class="log-container"
>
<n-empty v-if="filteredLogs.length === 0" description="暂无日志" />
<div
v-for="(log, i) in filteredLogs"
:key="i"
class="log-line"
>
<span class="log-time">{{ formatTime(log.ts) }}</span>
<span class="log-level" :style="{ color: getLevelColor(log.level) }">[{{ log.level.toUpperCase() }}]</span>
<span class="log-src">[{{ log.src }}]</span>
<span class="log-msg">{{ log.msg }}</span>
</div>
</div>
</n-spin>
</n-card>
</template>
<style scoped>
.log-container {
height: 400px;
overflow-y: auto;
background: #1e1e1e;
padding: 8px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
border-radius: 4px;
}
.log-line {
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
color: #d4d4d4;
}
.log-time {
color: #808080;
margin-right: 8px;
}
.log-level {
margin-right: 8px;
}
.log-src {
color: #a0a0a0;
margin-right: 8px;
}
.log-msg {
color: #d4d4d4;
}
</style>

View File

@@ -1,4 +1,4 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type AxiosError } from 'axios'
// Token 管理
const TOKEN_KEY = 'gotunnel_token'
@@ -7,6 +7,30 @@ export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
export const setToken = (token: string): void => localStorage.setItem(TOKEN_KEY, token)
export const removeToken = (): void => localStorage.removeItem(TOKEN_KEY)
// 统一 API 响应结构
export interface ApiResponse<T = any> {
code: number
data?: T
message?: string
}
// 业务错误码
export const ErrorCodes = {
Success: 0,
BadRequest: 400,
Unauthorized: 401,
Forbidden: 403,
NotFound: 404,
Conflict: 409,
InternalError: 500,
BadGateway: 502,
ClientNotOnline: 1001,
PluginNotFound: 1002,
InvalidClientID: 1003,
PluginDisabled: 1004,
ConfigSyncFailed: 1005,
}
// 创建 axios 实例
const instance: AxiosInstance = axios.create({
baseURL: '/api',
@@ -30,10 +54,39 @@ instance.interceptors.request.use(
}
)
// 响应拦截器
// 响应拦截器 - 处理统一响应格式
instance.interceptors.response.use(
(response) => response,
(error) => {
(response: AxiosResponse<ApiResponse>) => {
const apiResponse = response.data
// 检查业务错误码
if (apiResponse.code !== undefined && apiResponse.code !== ErrorCodes.Success) {
// 处理认证错误
if (apiResponse.code === ErrorCodes.Unauthorized && !isRedirecting) {
isRedirecting = true
removeToken()
setTimeout(() => {
window.location.replace('/login')
isRedirecting = false
}, 0)
}
// 返回包含业务错误信息的 rejected promise
return Promise.reject({
code: apiResponse.code,
message: apiResponse.message || 'Unknown error',
response: response
})
}
// 成功时返回 data 字段
return {
...response,
data: apiResponse.data !== undefined ? apiResponse.data : apiResponse
} as AxiosResponse
},
(error: AxiosError<ApiResponse>) => {
// 处理 HTTP 错误
if (error.response?.status === 401 && !isRedirecting) {
isRedirecting = true
removeToken()
@@ -42,6 +95,17 @@ instance.interceptors.response.use(
isRedirecting = false
}, 0)
}
// 尝试从响应中提取业务错误信息
const apiResponse = error.response?.data
if (apiResponse?.message) {
return Promise.reject({
code: apiResponse.code || error.response?.status,
message: apiResponse.message,
response: error.response
})
}
return Promise.reject(error)
}
)

View File

@@ -25,6 +25,11 @@ const router = createRouter({
name: 'plugins',
component: () => import('../views/PluginsView.vue'),
},
{
path: '/update',
name: 'update',
component: () => import('../views/UpdateView.vue'),
},
],
})

View File

@@ -7,14 +7,18 @@ export interface ProxyRule {
type?: string
enabled?: boolean
plugin_config?: Record<string, string>
plugin_managed?: boolean // 插件管理标记 - 由插件自动创建的规则
}
// 客户端已安装的插件
export interface ClientPlugin {
id: string // 插件实例唯一 ID
name: string
version: string
enabled: boolean
running: boolean
config?: Record<string, string>
remote_port?: number // 远程监听端口
}
// 插件配置字段
@@ -111,16 +115,37 @@ export interface StorePluginInfo {
icon?: string
download_url?: string
signature_url?: string
config_schema?: ConfigField[]
}
// JS 插件信息
export interface JSPlugin {
name: string
source: string
signature?: string
description: string
author: string
version?: string
auto_push: string[]
config: Record<string, string>
auto_start: boolean
enabled: boolean
}
// 规则配置模式集合
export type RuleSchemasMap = Record<string, RuleSchema>
// 日志条目
export interface LogEntry {
ts: number // Unix 时间戳 (毫秒)
level: string // 日志级别: debug, info, warn, error
msg: string // 日志消息
src: string // 来源: client, plugin:<name>
}
// 日志流选项
export interface LogStreamOptions {
lines?: number // 初始日志行数
follow?: boolean // 是否持续推送
level?: string // 日志级别过滤
}

View File

@@ -3,20 +3,21 @@ import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
NCard, NButton, NSpace, NTag, NTable, NEmpty,
NFormItem, NInput, NInputNumber, NSelect, NModal, NCheckbox, NSwitch,
NFormItem, NInput, NInputNumber, NSelect, NModal, NSwitch,
NIcon, useMessage, useDialog, NSpin
} from 'naive-ui'
import {
ArrowBackOutline, CreateOutline, TrashOutline,
PushOutline, PowerOutline, AddOutline, SaveOutline, CloseOutline,
DownloadOutline, SettingsOutline, StorefrontOutline
SettingsOutline, StorefrontOutline, RefreshOutline, StopOutline, PlayOutline, DocumentTextOutline
} from '@vicons/ionicons5'
import {
getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient,
getPlugins, installPluginsToClient, getClientPluginConfig, updateClientPluginConfig,
getStorePlugins, installStorePlugin
getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, restartClient,
getClientPluginConfig, updateClientPluginConfig,
getStorePlugins, installStorePlugin, getRuleSchemas, startClientPlugin, restartClientPlugin, stopClientPlugin, deleteClientPlugin
} from '../api'
import type { ProxyRule, PluginInfo, ClientPlugin, ConfigField, RuleSchema, StorePluginInfo } from '../types'
import type { ProxyRule, ClientPlugin, ConfigField, StorePluginInfo, RuleSchemasMap } from '../types'
import LogViewer from '../components/LogViewer.vue'
const route = useRoute()
const router = useRouter()
@@ -42,22 +43,30 @@ const builtinTypes = [
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' },
{ label: 'HTTP', value: 'http' },
{ label: 'HTTPS', value: 'https' }
{ label: 'HTTPS', value: 'https' },
{ label: 'SOCKS5', value: 'socks5' }
]
// 规则类型选项(内置 + 插件)
const typeOptions = ref([...builtinTypes])
// 插件 RuleSchema 映射
const pluginRuleSchemas = ref<Record<string, RuleSchema>>({})
// 插件 RuleSchema 映射(包含内置类型和插件类型)
const pluginRuleSchemas = ref<RuleSchemasMap>({})
// 加载规则配置模式
const loadRuleSchemas = async () => {
try {
const { data } = await getRuleSchemas()
pluginRuleSchemas.value = data || {}
} catch (e) {
console.error('Failed to load rule schemas', e)
}
}
// 判断类型是否需要本地地址
const needsLocalAddr = (type: string) => {
// 内置类型
if (['tcp', 'udp'].includes(type)) return true
// 插件类型:查询 RuleSchema
const schema = pluginRuleSchemas.value[type]
return schema?.needs_local_addr ?? false
return schema?.needs_local_addr ?? true // 默认需要
}
// 获取类型的额外字段
@@ -66,11 +75,6 @@ const getExtraFields = (type: string): ConfigField[] => {
return schema?.extra_fields || []
}
// 插件安装相关
const showInstallModal = ref(false)
const availablePlugins = ref<PluginInfo[]>([])
const selectedPlugins = ref<string[]>([])
// 插件配置相关
const showConfigModal = ref(false)
const configPluginName = ref('')
@@ -82,47 +86,23 @@ const configLoading = ref(false)
const showStoreModal = ref(false)
const storePlugins = ref<StorePluginInfo[]>([])
const storeLoading = ref(false)
const selectedStorePlugin = ref<StorePluginInfo | null>(null)
const storeInstalling = ref(false)
const storeInstalling = ref<string | null>(null) // 正在安装的插件名称
const loadPlugins = async () => {
try {
const { data } = await getPlugins()
availablePlugins.value = (data || []).filter(p => p.enabled)
// 安装配置模态框
const showInstallConfigModal = ref(false)
const installPlugin = ref<StorePluginInfo | null>(null)
const installRemotePort = ref<number | null>(8080)
const installAuthEnabled = ref(false)
const installAuthUsername = ref('')
const installAuthPassword = ref('')
// 更新类型选项:内置类型 + proxy 类型插件
const proxyPlugins = availablePlugins.value
.filter(p => p.type === 'proxy')
.map(p => ({ label: `${p.name.toUpperCase()} (插件)`, value: p.name }))
typeOptions.value = [...builtinTypes, ...proxyPlugins]
// 保存插件的 RuleSchema
const schemas: Record<string, RuleSchema> = {}
for (const p of availablePlugins.value) {
if (p.rule_schema) {
schemas[p.name] = p.rule_schema
}
}
pluginRuleSchemas.value = schemas
} catch (e) {
console.error('Failed to load plugins', e)
}
}
const openInstallModal = async () => {
await loadPlugins()
// 过滤掉已安装的插件
const installedNames = clientPlugins.value.map(p => p.name)
availablePlugins.value = availablePlugins.value.filter(p => !installedNames.includes(p.name))
selectedPlugins.value = []
showInstallModal.value = true
}
// 日志查看相关
const showLogViewer = ref(false)
// 商店插件相关函数
const openStoreModal = async () => {
showStoreModal.value = true
storeLoading.value = true
selectedStorePlugin.value = null
try {
const { data } = await getStorePlugins()
storePlugins.value = (data.plugins || []).filter(p => p.download_url)
@@ -139,26 +119,43 @@ const handleInstallStorePlugin = async (plugin: StorePluginInfo) => {
message.error('该插件没有下载地址')
return
}
storeInstalling.value = true
selectedStorePlugin.value = plugin
// 打开配置模态框
installPlugin.value = plugin
installRemotePort.value = 8080
installAuthEnabled.value = false
installAuthUsername.value = ''
installAuthPassword.value = ''
showInstallConfigModal.value = true
}
const confirmInstallPlugin = async () => {
if (!installPlugin.value) return
storeInstalling.value = installPlugin.value.name
try {
await installStorePlugin(plugin.name, plugin.download_url, clientId)
message.success(`已安装 ${plugin.name}`)
await installStorePlugin(
installPlugin.value.name,
installPlugin.value.download_url || '',
installPlugin.value.signature_url || '',
clientId,
installRemotePort.value || 8080,
installPlugin.value.version,
installPlugin.value.config_schema,
installAuthEnabled.value,
installAuthUsername.value,
installAuthPassword.value
)
message.success(`已安装 ${installPlugin.value.name}`)
showInstallConfigModal.value = false
showStoreModal.value = false
await loadClient()
} catch (e: any) {
message.error(e.response?.data || '安装失败')
} finally {
storeInstalling.value = false
selectedStorePlugin.value = null
storeInstalling.value = null
}
}
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = { proxy: '协议', app: '应用', service: '服务', tool: '工具' }
return labels[type] || type
}
const loadClient = async () => {
try {
const { data } = await getClient(clientId)
@@ -174,8 +171,8 @@ const loadClient = async () => {
}
onMounted(() => {
loadRuleSchemas() // 加载内置协议配置模式
loadClient()
loadPlugins()
})
// 打开重命名弹窗
@@ -201,11 +198,14 @@ const saveRename = async () => {
}
const startEdit = () => {
editRules.value = rules.value.map(rule => ({
...rule,
type: rule.type || 'tcp',
enabled: rule.enabled !== false
}))
// 只编辑非插件管理的规则
editRules.value = rules.value
.filter(rule => !rule.plugin_managed)
.map(rule => ({
...rule,
type: rule.type || 'tcp',
enabled: rule.enabled !== false
}))
editing.value = true
}
@@ -225,7 +225,10 @@ const removeRule = (index: number) => {
const saveEdit = async () => {
try {
await updateClient(clientId, { id: clientId, nickname: nickname.value, rules: editRules.value })
// 合并插件管理的规则和编辑后的规则
const pluginManagedRules = rules.value.filter(r => r.plugin_managed)
const allRules = [...pluginManagedRules, ...editRules.value]
await updateClient(clientId, { id: clientId, nickname: nickname.value, rules: allRules })
editing.value = false
message.success('保存成功')
await loadClient()
@@ -288,25 +291,69 @@ const disconnect = () => {
})
}
const installPlugins = async () => {
if (selectedPlugins.value.length === 0) {
message.warning('请选择要安装的插件')
return
}
// 重启客户端
const handleRestartClient = () => {
dialog.warning({
title: '确认重启',
content: '确定要重启此客户端吗?客户端将断开连接并自动重连。',
positiveText: '重启',
negativeText: '取消',
onPositiveClick: async () => {
try {
await restartClient(clientId)
message.success('重启命令已发送,客户端将自动重连')
setTimeout(() => loadClient(), 3000)
} catch (e: any) {
message.error(e.response?.data || '重启失败')
}
}
})
}
// 启动客户端插件
const handleStartPlugin = async (plugin: ClientPlugin) => {
const rule = rules.value.find(r => r.type === plugin.name)
const ruleName = rule?.name || plugin.name
try {
await installPluginsToClient(clientId, selectedPlugins.value)
message.success(`推送 ${selectedPlugins.value.length} 个插件到客户端`)
showInstallModal.value = false
await loadClient() // 刷新客户端数据
await startClientPlugin(clientId, plugin.id, ruleName)
message.success(`启动 ${plugin.name}`)
plugin.running = true
} catch (e: any) {
message.error(e.response?.data || '安装失败')
message.error(e.message || '启动失败')
}
}
// 重启客户端插件
const handleRestartPlugin = async (plugin: ClientPlugin) => {
// 找到使用此插件的规则
const rule = rules.value.find(r => r.type === plugin.name)
const ruleName = rule?.name || plugin.name
try {
await restartClientPlugin(clientId, plugin.id, ruleName)
message.success(`已重启 ${plugin.name}`)
plugin.running = true
} catch (e: any) {
message.error(e.message || '重启失败')
}
}
// 停止客户端插件
const handleStopPlugin = async (plugin: ClientPlugin) => {
const rule = rules.value.find(r => r.type === plugin.name)
const ruleName = rule?.name || plugin.name
try {
await stopClientPlugin(clientId, plugin.id, ruleName)
message.success(`已停止 ${plugin.name}`)
plugin.running = false
} catch (e: any) {
message.error(e.message || '停止失败')
}
}
const toggleClientPlugin = async (plugin: ClientPlugin) => {
const newEnabled = !plugin.enabled
const updatedPlugins = clientPlugins.value.map(p =>
p.name === plugin.name ? { ...p, enabled: newEnabled } : p
p.id === plugin.id ? { ...p, enabled: newEnabled } : p
)
try {
await updateClient(clientId, {
@@ -357,6 +404,25 @@ const savePluginConfig = async () => {
message.error(e.response?.data || '保存失败')
}
}
// 删除客户端插件
const handleDeletePlugin = (plugin: ClientPlugin) => {
dialog.warning({
title: '确认删除',
content: `确定要删除插件 ${plugin.name} 吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await deleteClientPlugin(clientId, plugin.id)
message.success(`已删除 ${plugin.name}`)
await loadClient()
} catch (e: any) {
message.error(e.response?.data || '删除失败')
}
}
})
}
</script>
<template>
@@ -390,9 +456,9 @@ const savePluginConfig = async () => {
<template #icon><n-icon><PushOutline /></n-icon></template>
推送配置
</n-button>
<n-button type="success" @click="openInstallModal">
<template #icon><n-icon><DownloadOutline /></n-icon></template>
安装插件
<n-button @click="showLogViewer = true">
<template #icon><n-icon><DocumentTextOutline /></n-icon></template>
查看日志
</n-button>
<n-button @click="openStoreModal">
<template #icon><n-icon><StorefrontOutline /></n-icon></template>
@@ -402,6 +468,10 @@ const savePluginConfig = async () => {
<template #icon><n-icon><PowerOutline /></n-icon></template>
断开连接
</n-button>
<n-button type="error" @click="handleRestartClient">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
重启客户端
</n-button>
</template>
<template v-if="!editing">
<n-button type="primary" @click="startEdit">
@@ -443,6 +513,7 @@ const savePluginConfig = async () => {
<th>远程端口</th>
<th>类型</th>
<th>状态</th>
<th>来源</th>
</tr>
</thead>
<tbody>
@@ -461,6 +532,10 @@ const savePluginConfig = async () => {
{{ rule.enabled !== false ? '启用' : '禁用' }}
</n-tag>
</td>
<td>
<n-tag v-if="rule.plugin_managed" size="small" type="info">插件</n-tag>
<n-tag v-else size="small" type="default">手动</n-tag>
</td>
</tr>
</tbody>
</n-table>
@@ -495,12 +570,45 @@ const savePluginConfig = async () => {
<!-- 插件额外字段 -->
<template v-for="field in getExtraFields(rule.type || '')" :key="field.key">
<n-form-item :label="field.label" :show-feedback="false">
<!-- 字符串输入 -->
<n-input
v-if="field.type === 'string'"
:value="rule.plugin_config?.[field.key] || field.default || ''"
@update:value="(v: string) => { if (!rule.plugin_config) rule.plugin_config = {}; rule.plugin_config[field.key] = v }"
:placeholder="field.description"
/>
<!-- 密码输入 -->
<n-input
v-else-if="field.type === 'password'"
:value="rule.plugin_config?.[field.key] || ''"
@update:value="(v: string) => { if (!rule.plugin_config) rule.plugin_config = {}; rule.plugin_config[field.key] = v }"
type="password"
show-password-on="click"
:placeholder="field.description"
/>
<!-- 数字输入 -->
<n-input-number
v-else-if="field.type === 'number'"
:value="rule.plugin_config?.[field.key] ? Number(rule.plugin_config[field.key]) : undefined"
@update:value="(v: number | null) => { if (!rule.plugin_config) rule.plugin_config = {}; rule.plugin_config[field.key] = v !== null ? String(v) : '' }"
:placeholder="field.description"
:show-button="false"
style="width: 120px;"
/>
<!-- 布尔开关 -->
<n-switch
v-else-if="field.type === 'bool'"
:value="rule.plugin_config?.[field.key] === 'true'"
@update:value="(v: boolean) => { if (!rule.plugin_config) rule.plugin_config = {}; rule.plugin_config[field.key] = String(v) }"
/>
<!-- 下拉选择 -->
<n-select
v-else-if="field.type === 'select'"
:value="rule.plugin_config?.[field.key] || field.default"
@update:value="(v: string) => { if (!rule.plugin_config) rule.plugin_config = {}; rule.plugin_config[field.key] = v }"
:options="(field.options || []).map(o => ({ label: o, value: o }))"
style="width: 120px;"
/>
</n-form-item>
</template>
<n-button v-if="editRules.length > 1" quaternary type="error" @click="removeRule(i)">
@@ -525,60 +633,50 @@ const savePluginConfig = async () => {
<th>名称</th>
<th>版本</th>
<th>状态</th>
<th>启用</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="plugin in clientPlugins" :key="plugin.name">
<tr v-for="plugin in clientPlugins" :key="plugin.id">
<td>{{ plugin.name }}</td>
<td>v{{ plugin.version }}</td>
<td>
<n-tag v-if="plugin.running" type="success" size="small">运行中</n-tag>
<n-tag v-else type="default" size="small">已停止</n-tag>
</td>
<td>
<n-switch :value="plugin.enabled" @update:value="toggleClientPlugin(plugin)" />
</td>
<td>
<n-button size="small" quaternary @click="openConfigModal(plugin)">
<template #icon><n-icon><SettingsOutline /></n-icon></template>
配置
</n-button>
<n-space :size="4">
<n-button size="small" quaternary @click="openConfigModal(plugin)">
<template #icon><n-icon><SettingsOutline /></n-icon></template>
配置
</n-button>
<n-button v-if="online && plugin.enabled && plugin.running" size="small" quaternary type="info" @click="handleRestartPlugin(plugin)">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
重启
</n-button>
<n-button v-if="online && plugin.enabled && !plugin.running" size="small" quaternary type="success" @click="handleStartPlugin(plugin)">
<template #icon><n-icon><PlayOutline /></n-icon></template>
启动
</n-button>
<n-button v-if="online && plugin.enabled && plugin.running" size="small" quaternary type="warning" @click="handleStopPlugin(plugin)">
<template #icon><n-icon><StopOutline /></n-icon></template>
停止
</n-button>
<n-button size="small" quaternary type="error" @click="handleDeletePlugin(plugin)">
<template #icon><n-icon><TrashOutline /></n-icon></template>
删除
</n-button>
</n-space>
</td>
</tr>
</tbody>
</n-table>
</n-card>
<!-- 安装插件模态框 -->
<n-modal v-model:show="showInstallModal" preset="card" title="安装插件到客户端" style="width: 500px;">
<n-empty v-if="availablePlugins.length === 0" description="暂无可用插件" />
<n-space v-else vertical :size="12">
<n-card v-for="plugin in availablePlugins" :key="plugin.name" size="small">
<n-space justify="space-between" align="center">
<n-space vertical :size="4">
<n-space align="center">
<span style="font-weight: 500;">{{ plugin.name }}</span>
<n-tag size="small">{{ getTypeLabel(plugin.type) }}</n-tag>
</n-space>
<span style="color: #666; font-size: 12px;">{{ plugin.description }}</span>
</n-space>
<n-checkbox
:checked="selectedPlugins.includes(plugin.name)"
@update:checked="(v: boolean) => {
if (v) selectedPlugins.push(plugin.name)
else selectedPlugins = selectedPlugins.filter(n => n !== plugin.name)
}"
/>
</n-space>
</n-card>
</n-space>
<template #footer>
<n-space justify="end">
<n-button @click="showInstallModal = false">取消</n-button>
<n-button type="primary" @click="installPlugins" :disabled="selectedPlugins.length === 0">
安装 ({{ selectedPlugins.length }})
</n-button>
</n-space>
</template>
</n-modal>
<!-- 插件配置模态框 -->
<n-modal v-model:show="showConfigModal" preset="card" :title="`${configPluginName} 配置`" style="width: 500px;">
<n-empty v-if="configLoading" description="加载中..." />
@@ -665,7 +763,7 @@ const savePluginConfig = async () => {
<n-button
size="small"
type="primary"
:loading="storeInstalling && selectedStorePlugin?.name === plugin.name"
:loading="storeInstalling === plugin.name"
@click="handleInstallStorePlugin(plugin)"
>
安装
@@ -680,5 +778,48 @@ const savePluginConfig = async () => {
</n-space>
</template>
</n-modal>
<!-- 安装配置模态框 -->
<n-modal v-model:show="showInstallConfigModal" preset="card" title="安装配置" style="width: 450px;">
<n-space vertical :size="16">
<div v-if="installPlugin">
<p style="margin: 0 0 8px 0;"><strong>插件:</strong> {{ installPlugin.name }}</p>
<p style="margin: 0; color: #666;">{{ installPlugin.description }}</p>
</div>
<div>
<p style="margin: 0 0 8px 0; color: #666; font-size: 13px;">远程端口:</p>
<n-input-number
v-model:value="installRemotePort"
:min="1"
:max="65535"
placeholder="输入端口号"
style="width: 100%;"
/>
</div>
<div>
<n-space align="center" :size="8">
<n-switch v-model:value="installAuthEnabled" />
<span style="color: #666;">启用 HTTP Basic Auth</span>
</n-space>
</div>
<template v-if="installAuthEnabled">
<n-input v-model:value="installAuthUsername" placeholder="用户名" />
<n-input v-model:value="installAuthPassword" type="password" placeholder="密码" show-password-on="click" />
</template>
</n-space>
<template #footer>
<n-space justify="end">
<n-button @click="showInstallConfigModal = false">取消</n-button>
<n-button type="primary" :loading="!!storeInstalling" @click="confirmInstallPlugin">
安装
</n-button>
</n-space>
</template>
</n-modal>
<!-- 日志查看模态框 -->
<n-modal v-model:show="showLogViewer" preset="card" style="width: 900px; max-width: 95vw;">
<LogViewer :client-id="clientId" :visible="showLogViewer" @close="showLogViewer = false" />
</n-modal>
</div>
</template>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi, NEmpty } from 'naive-ui'
import { NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi, NEmpty, NIcon } from 'naive-ui'
import { ExtensionPuzzleOutline, CloudDownloadOutline } from '@vicons/ionicons5'
import { getClients } from '../api'
import type { ClientStatus } from '../types'
@@ -35,10 +36,22 @@ const viewClient = (id: string) => {
<template>
<div class="home">
<div style="margin-bottom: 24px;">
<h2 style="margin: 0 0 8px 0;">客户端管理</h2>
<p style="margin: 0; color: #666;">查看已连接的隧道客户端</p>
</div>
<n-space justify="space-between" align="center" style="margin-bottom: 24px;">
<div>
<h2 style="margin: 0 0 8px 0;">客户端管理</h2>
<p style="margin: 0; color: #666;">查看已连接的隧道客户端</p>
</div>
<n-space>
<n-button @click="router.push('/plugins')">
<template #icon><n-icon><ExtensionPuzzleOutline /></n-icon></template>
扩展商店
</n-button>
<n-button @click="router.push('/update')">
<template #icon><n-icon><CloudDownloadOutline /></n-icon></template>
系统更新
</n-button>
</n-space>
</n-space>
<n-grid :cols="3" :x-gap="16" :y-gap="16" style="margin-bottom: 24px;">
<n-gi>

View File

@@ -4,12 +4,12 @@ import { useRouter } from 'vue-router'
import {
NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi,
NEmpty, NSpin, NIcon, NSwitch, NTabs, NTabPane, useMessage,
NSelect, NModal
NSelect, NModal, NInput, NInputNumber
} from 'naive-ui'
import { ArrowBackOutline, ExtensionPuzzleOutline, StorefrontOutline, CodeSlashOutline } from '@vicons/ionicons5'
import { ArrowBackOutline, ExtensionPuzzleOutline, StorefrontOutline, CodeSlashOutline, SettingsOutline } from '@vicons/ionicons5'
import {
getPlugins, enablePlugin, disablePlugin, getStorePlugins, getJSPlugins,
pushJSPluginToClient, getClients, installStorePlugin
pushJSPluginToClient, getClients, installStorePlugin, updateJSPluginConfig, setJSPluginEnabled
} from '../api'
import type { PluginInfo, StorePluginInfo, JSPlugin, ClientStatus } from '../types'
@@ -131,26 +131,118 @@ const loadClients = async () => {
}
}
const handlePushJSPlugin = async (pluginName: string, clientId: string) => {
// JS 插件推送相关
const showPushModal = ref(false)
const selectedJSPlugin = ref<JSPlugin | null>(null)
const pushClientId = ref('')
const pushRemotePort = ref<number | null>(8080)
const pushing = ref(false)
const openPushModal = (plugin: JSPlugin) => {
selectedJSPlugin.value = plugin
pushClientId.value = ''
pushRemotePort.value = 8080
showPushModal.value = true
}
const handlePushJSPlugin = async () => {
if (!selectedJSPlugin.value || !pushClientId.value) {
message.warning('请选择要推送到的客户端')
return
}
pushing.value = true
try {
await pushJSPluginToClient(pluginName, clientId)
message.success(`已推送 ${pluginName}${clientId}`)
} catch (e) {
message.error('推送失败')
await pushJSPluginToClient(selectedJSPlugin.value.name, pushClientId.value, pushRemotePort.value || 0)
message.success(`已推送 ${selectedJSPlugin.value.name}${pushClientId.value},监听端口: ${pushRemotePort.value || '未指定'}`)
showPushModal.value = false
} catch (e: any) {
message.error(e.response?.data || '推送失败')
} finally {
pushing.value = false
}
}
const onlineClients = computed(() => clients.value.filter(c => c.online))
// JS 插件配置相关
const showJSConfigModal = ref(false)
const currentJSPlugin = ref<JSPlugin | null>(null)
const jsConfigItems = ref<Array<{ key: string; value: string }>>([])
const jsConfigSaving = ref(false)
const openJSConfigModal = (plugin: JSPlugin) => {
currentJSPlugin.value = plugin
// 将 config 转换为数组形式便于编辑
jsConfigItems.value = Object.entries(plugin.config || {}).map(([key, value]) => ({ key, value }))
if (jsConfigItems.value.length === 0) {
jsConfigItems.value.push({ key: '', value: '' })
}
showJSConfigModal.value = true
}
const addJSConfigItem = () => {
jsConfigItems.value.push({ key: '', value: '' })
}
const removeJSConfigItem = (index: number) => {
jsConfigItems.value.splice(index, 1)
}
const saveJSPluginConfig = async () => {
if (!currentJSPlugin.value) return
jsConfigSaving.value = true
try {
// 将数组转换回对象
const config: Record<string, string> = {}
for (const item of jsConfigItems.value) {
if (item.key.trim()) {
config[item.key.trim()] = item.value
}
}
await updateJSPluginConfig(currentJSPlugin.value.name, config)
// 更新本地数据
const plugin = jsPlugins.value.find(p => p.name === currentJSPlugin.value!.name)
if (plugin) {
plugin.config = config
}
message.success('配置已保存')
showJSConfigModal.value = false
} catch (e: any) {
message.error(e.response?.data || '保存失败')
} finally {
jsConfigSaving.value = false
}
}
// 切换 JS 插件启用状态
const toggleJSPlugin = async (plugin: JSPlugin) => {
try {
await setJSPluginEnabled(plugin.name, !plugin.enabled)
plugin.enabled = !plugin.enabled
message.success(plugin.enabled ? `已启用 ${plugin.name}` : `已禁用 ${plugin.name}`)
} catch (e: any) {
message.error(e.response?.data || '操作失败')
}
}
// 商店插件安装相关
const showInstallModal = ref(false)
const selectedStorePlugin = ref<StorePluginInfo | null>(null)
const selectedClientId = ref('')
const installing = ref(false)
const installRemotePort = ref<number | null>(8080)
const installAuthEnabled = ref(false)
const installAuthUsername = ref('')
const installAuthPassword = ref('')
const openInstallModal = (plugin: StorePluginInfo) => {
selectedStorePlugin.value = plugin
selectedClientId.value = ''
installRemotePort.value = 8080
installAuthEnabled.value = false
installAuthUsername.value = ''
installAuthPassword.value = ''
showInstallModal.value = true
}
@@ -173,9 +265,15 @@ const handleInstallStorePlugin = async () => {
selectedStorePlugin.value.name,
selectedStorePlugin.value.download_url,
selectedStorePlugin.value.signature_url,
selectedClientId.value
selectedClientId.value,
installRemotePort.value || 8080,
selectedStorePlugin.value.version,
selectedStorePlugin.value.config_schema,
installAuthEnabled.value,
installAuthUsername.value,
installAuthPassword.value
)
message.success(`已安装 ${selectedStorePlugin.value.name} 到客户端`)
message.success(`已安装 ${selectedStorePlugin.value.name}`)
showInstallModal.value = false
} catch (e: any) {
message.error(e.response?.data || '安装失败')
@@ -246,8 +344,8 @@ onMounted(() => {
<n-tag size="small" :type="getTypeColor(plugin.type)">
{{ getTypeLabel(plugin.type) }}
</n-tag>
<n-tag size="small" :type="plugin.source === 'builtin' ? 'default' : 'warning'">
{{ plugin.source === 'builtin' ? '内置' : 'WASM' }}
<n-tag size="small" :type="plugin.source === 'builtin' ? 'default' : 'info'">
{{ plugin.source === 'builtin' ? '内置' : 'JS' }}
</n-tag>
</n-space>
<p style="margin: 0; color: #666;">{{ plugin.description }}</p>
@@ -301,15 +399,6 @@ onMounted(() => {
<!-- JS 插件 -->
<n-tab-pane name="js" tab="JS 插件">
<!-- 安全加固暂时禁用 Web UI 创建功能
<n-space justify="end" style="margin-bottom: 16px;">
<n-button type="primary" @click="showJSModal = true">
<template #icon><n-icon><AddOutline /></n-icon></template>
新建 JS 插件
</n-button>
</n-space>
-->
<n-spin :show="jsLoading">
<n-empty v-if="!jsLoading && jsPlugins.length === 0" description="暂无 JS 插件" />
@@ -320,36 +409,47 @@ onMounted(() => {
<n-space align="center">
<n-icon size="24" color="#f0a020"><CodeSlashOutline /></n-icon>
<span>{{ plugin.name }}</span>
<n-tag v-if="plugin.version" size="small">v{{ plugin.version }}</n-tag>
</n-space>
</template>
<template #header-extra>
<n-space>
<n-select
v-if="onlineClients.length > 0"
placeholder="推送到..."
size="small"
style="width: 120px;"
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
@update:value="(v: string) => handlePushJSPlugin(plugin.name, v)"
/>
<!-- 安全加固暂时禁用删除功能
<n-popconfirm @positive-click="handleDeleteJSPlugin(plugin.name)">
<template #trigger>
<n-button size="small" type="error" quaternary>删除</n-button>
</template>
确定删除此插件
</n-popconfirm>
-->
</n-space>
<n-switch :value="plugin.enabled" @update:value="toggleJSPlugin(plugin)" />
</template>
<n-space vertical :size="8">
<n-space>
<n-tag size="small" type="warning">JS</n-tag>
<n-tag v-if="plugin.auto_start" size="small" type="success">自动启动</n-tag>
<n-tag v-if="plugin.signature" size="small" type="info">已签名</n-tag>
</n-space>
<p style="margin: 0; color: #666;">{{ plugin.description || '无描述' }}</p>
<p v-if="plugin.author" style="margin: 0; color: #999; font-size: 12px;">作者: {{ plugin.author }}</p>
<!-- 配置预览 -->
<div v-if="Object.keys(plugin.config || {}).length > 0" style="margin-top: 8px;">
<p style="margin: 0 0 4px 0; color: #999; font-size: 12px;">配置:</p>
<n-space :size="4" wrap>
<n-tag v-for="(value, key) in plugin.config" :key="key" size="small" type="default">
{{ key }}: {{ value.length > 10 ? value.slice(0, 10) + '...' : value }}
</n-tag>
</n-space>
</div>
</n-space>
<template #action>
<n-space justify="space-between">
<n-button size="small" quaternary @click="openJSConfigModal(plugin)">
<template #icon><n-icon><SettingsOutline /></n-icon></template>
配置
</n-button>
<n-button
v-if="onlineClients.length > 0"
size="small"
type="primary"
@click="openPushModal(plugin)"
>
推送到客户端
</n-button>
</n-space>
</template>
</n-card>
</n-gi>
</n-grid>
@@ -364,7 +464,7 @@ onMounted(() => {
-->
<!-- 安装商店插件模态框 -->
<n-modal v-model:show="showInstallModal" preset="card" title="安装插件" style="width: 400px;">
<n-modal v-model:show="showInstallModal" preset="card" title="安装插件" style="width: 450px;">
<n-space vertical :size="16">
<div v-if="selectedStorePlugin">
<p style="margin: 0 0 8px 0;"><strong>插件:</strong> {{ selectedStorePlugin.name }}</p>
@@ -375,6 +475,26 @@ onMounted(() => {
placeholder="选择要安装到的客户端"
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
/>
<div>
<p style="margin: 0 0 8px 0; color: #666; font-size: 13px;">远程端口:</p>
<n-input-number
v-model:value="installRemotePort"
:min="1"
:max="65535"
placeholder="输入端口号"
style="width: 100%;"
/>
</div>
<div>
<n-space align="center" :size="8">
<n-switch v-model:value="installAuthEnabled" />
<span style="color: #666;">启用 HTTP Basic Auth</span>
</n-space>
</div>
<template v-if="installAuthEnabled">
<n-input v-model:value="installAuthUsername" placeholder="用户名" />
<n-input v-model:value="installAuthPassword" type="password" placeholder="密码" show-password-on="click" />
</template>
</n-space>
<template #footer>
<n-space justify="end">
@@ -390,5 +510,67 @@ onMounted(() => {
</n-space>
</template>
</n-modal>
<!-- JS 插件配置模态框 -->
<n-modal v-model:show="showJSConfigModal" preset="card" :title="`${currentJSPlugin?.name || ''} 配置`" style="width: 500px;">
<n-space vertical :size="12">
<p style="margin: 0; color: #666; font-size: 13px;">编辑插件配置参数键值对形式</p>
<div v-for="(item, index) in jsConfigItems" :key="index">
<n-space :size="8" align="center">
<n-input v-model:value="item.key" placeholder="参数名" style="width: 150px;" />
<n-input v-model:value="item.value" placeholder="参数值" style="width: 200px;" />
<n-button v-if="jsConfigItems.length > 1" quaternary type="error" size="small" @click="removeJSConfigItem(index)">
删除
</n-button>
</n-space>
</div>
<n-button dashed size="small" @click="addJSConfigItem">添加配置项</n-button>
</n-space>
<template #footer>
<n-space justify="end">
<n-button @click="showJSConfigModal = false">取消</n-button>
<n-button type="primary" :loading="jsConfigSaving" @click="saveJSPluginConfig">保存</n-button>
</n-space>
</template>
</n-modal>
<!-- JS 插件推送模态框 -->
<n-modal v-model:show="showPushModal" preset="card" title="推送插件到客户端" style="width: 400px;">
<n-space vertical :size="16">
<div v-if="selectedJSPlugin">
<p style="margin: 0 0 8px 0;"><strong>插件:</strong> {{ selectedJSPlugin.name }}</p>
<p style="margin: 0; color: #666;">{{ selectedJSPlugin.description || '无描述' }}</p>
</div>
<n-select
v-model:value="pushClientId"
placeholder="选择要推送到的客户端"
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
/>
<div>
<p style="margin: 0 0 8px 0; color: #666; font-size: 13px;">远程端口服务端监听端口:</p>
<n-input-number
v-model:value="pushRemotePort"
:min="1"
:max="65535"
placeholder="输入端口号"
style="width: 100%;"
/>
<p style="margin: 8px 0 0 0; color: #999; font-size: 12px;">用户可以通过 服务端IP:端口 访问此插件提供的服务</p>
</div>
</n-space>
<template #footer>
<n-space justify="end">
<n-button @click="showPushModal = false">取消</n-button>
<n-button
type="primary"
:loading="pushing"
:disabled="!pushClientId"
@click="handlePushJSPlugin"
>
推送
</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>

View File

@@ -0,0 +1,328 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import {
NCard, NButton, NSpace, NTag, NGrid, NGi, NEmpty, NSpin, NIcon,
NAlert, NSelect, useMessage, useDialog
} from 'naive-ui'
import { ArrowBackOutline, CloudDownloadOutline, RefreshOutline, RocketOutline } from '@vicons/ionicons5'
import {
getVersionInfo, checkServerUpdate, checkClientUpdate, applyServerUpdate, applyClientUpdate,
getClients, type UpdateInfo, type VersionInfo
} from '../api'
import type { ClientStatus } from '../types'
const router = useRouter()
const message = useMessage()
const dialog = useDialog()
const versionInfo = ref<VersionInfo | null>(null)
const serverUpdate = ref<UpdateInfo | null>(null)
const clientUpdate = ref<UpdateInfo | null>(null)
const clients = ref<ClientStatus[]>([])
const loading = ref(true)
const checkingServer = ref(false)
const checkingClient = ref(false)
const updatingServer = ref(false)
const selectedClientId = ref('')
const onlineClients = computed(() => clients.value.filter(c => c.online))
const loadVersionInfo = async () => {
try {
const { data } = await getVersionInfo()
versionInfo.value = data
} catch (e) {
console.error('Failed to load version info', e)
}
}
const loadClients = async () => {
try {
const { data } = await getClients()
clients.value = data || []
} catch (e) {
console.error('Failed to load clients', e)
}
}
const handleCheckServerUpdate = async () => {
checkingServer.value = true
try {
const { data } = await checkServerUpdate()
serverUpdate.value = data
if (data.available) {
message.success('发现新版本: ' + data.latest)
} else {
message.info('已是最新版本')
}
} catch (e: any) {
message.error(e.response?.data || '检查更新失败')
} finally {
checkingServer.value = false
}
}
const handleCheckClientUpdate = async () => {
checkingClient.value = true
try {
const { data } = await checkClientUpdate()
clientUpdate.value = data
if (data.download_url) {
message.success('找到客户端更新包: ' + data.latest)
} else {
message.warning('未找到对应平台的更新包')
}
} catch (e: any) {
message.error(e.response?.data || '检查更新失败')
} finally {
checkingClient.value = false
}
}
const handleApplyServerUpdate = () => {
if (!serverUpdate.value?.download_url) {
message.error('没有可用的下载链接')
return
}
dialog.warning({
title: '确认更新服务端',
content: `即将更新服务端到 ${serverUpdate.value.latest},更新后服务器将自动重启。确定要继续吗?`,
positiveText: '更新并重启',
negativeText: '取消',
onPositiveClick: async () => {
updatingServer.value = true
try {
await applyServerUpdate(serverUpdate.value!.download_url)
message.success('更新已开始,服务器将在几秒后重启')
// 显示倒计时或等待
setTimeout(() => {
window.location.reload()
}, 5000)
} catch (e: any) {
message.error(e.response?.data || '更新失败')
updatingServer.value = false
}
}
})
}
const handleApplyClientUpdate = async () => {
if (!selectedClientId.value) {
message.warning('请选择要更新的客户端')
return
}
if (!clientUpdate.value?.download_url) {
message.error('没有可用的下载链接')
return
}
const clientName = onlineClients.value.find(c => c.id === selectedClientId.value)?.nickname || selectedClientId.value
dialog.warning({
title: '确认更新客户端',
content: `即将更新客户端 "${clientName}" 到 ${clientUpdate.value.latest},更新后客户端将自动重启。确定要继续吗?`,
positiveText: '更新',
negativeText: '取消',
onPositiveClick: async () => {
try {
await applyClientUpdate(selectedClientId.value, clientUpdate.value!.download_url)
message.success(`更新命令已发送到客户端 ${clientName}`)
} catch (e: any) {
message.error(e.response?.data || '更新失败')
}
}
})
}
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
onMounted(async () => {
await Promise.all([loadVersionInfo(), loadClients()])
loading.value = false
})
</script>
<template>
<div class="update-view">
<n-space justify="space-between" align="center" style="margin-bottom: 24px;">
<div>
<h2 style="margin: 0 0 8px 0;">系统更新</h2>
<p style="margin: 0; color: #666;">检查并应用服务端和客户端更新</p>
</div>
<n-button quaternary @click="router.push('/')">
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
返回首页
</n-button>
</n-space>
<n-spin :show="loading">
<!-- 当前版本信息 -->
<n-card title="当前版本" style="margin-bottom: 16px;">
<n-grid v-if="versionInfo" :cols="6" :x-gap="16" responsive="screen" cols-s="2" cols-m="3">
<n-gi>
<div class="info-item">
<span class="label">版本号</span>
<span class="value">{{ versionInfo.version }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">Git 提交</span>
<span class="value">{{ versionInfo.git_commit?.slice(0, 8) || 'N/A' }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">构建时间</span>
<span class="value">{{ versionInfo.build_time || 'N/A' }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">Go 版本</span>
<span class="value">{{ versionInfo.go_version }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">操作系统</span>
<span class="value">{{ versionInfo.os }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">架构</span>
<span class="value">{{ versionInfo.arch }}</span>
</div>
</n-gi>
</n-grid>
<n-empty v-else description="加载中..." />
</n-card>
<n-grid :cols="2" :x-gap="16" responsive="screen" cols-s="1">
<!-- 服务端更新 -->
<n-gi>
<n-card title="服务端更新">
<template #header-extra>
<n-button size="small" :loading="checkingServer" @click="handleCheckServerUpdate">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
检查更新
</n-button>
</template>
<n-empty v-if="!serverUpdate" description="点击检查更新按钮查看是否有新版本" />
<template v-else>
<n-alert v-if="serverUpdate.available" type="success" style="margin-bottom: 16px;">
发现新版本 {{ serverUpdate.latest }}当前版本 {{ serverUpdate.current }}
</n-alert>
<n-alert v-else type="info" style="margin-bottom: 16px;">
当前已是最新版本 {{ serverUpdate.current }}
</n-alert>
<n-space vertical :size="12">
<div v-if="serverUpdate.download_url">
<p style="margin: 0 0 8px 0; color: #666;">
下载文件: {{ serverUpdate.asset_name }}
<n-tag size="small" style="margin-left: 8px;">{{ formatBytes(serverUpdate.asset_size) }}</n-tag>
</p>
</div>
<div v-if="serverUpdate.release_note" style="max-height: 150px; overflow-y: auto;">
<p style="margin: 0 0 4px 0; color: #666; font-size: 12px;">更新日志:</p>
<pre style="margin: 0; white-space: pre-wrap; font-size: 12px; color: #333;">{{ serverUpdate.release_note }}</pre>
</div>
<n-button
v-if="serverUpdate.available && serverUpdate.download_url"
type="primary"
:loading="updatingServer"
@click="handleApplyServerUpdate"
>
<template #icon><n-icon><CloudDownloadOutline /></n-icon></template>
下载并更新服务端
</n-button>
</n-space>
</template>
</n-card>
</n-gi>
<!-- 客户端更新 -->
<n-gi>
<n-card title="客户端更新">
<template #header-extra>
<n-button size="small" :loading="checkingClient" @click="handleCheckClientUpdate">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
检查更新
</n-button>
</template>
<n-empty v-if="!clientUpdate" description="点击检查更新按钮查看客户端更新" />
<template v-else>
<n-space vertical :size="12">
<div v-if="clientUpdate.download_url">
<p style="margin: 0 0 8px 0; color: #666;">
最新版本: {{ clientUpdate.latest }}
</p>
<p style="margin: 0 0 8px 0; color: #666;">
下载文件: {{ clientUpdate.asset_name }}
<n-tag size="small" style="margin-left: 8px;">{{ formatBytes(clientUpdate.asset_size) }}</n-tag>
</p>
</div>
<n-empty v-if="onlineClients.length === 0" description="没有在线的客户端" />
<template v-else>
<n-select
v-model:value="selectedClientId"
placeholder="选择要更新的客户端"
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
/>
<n-button
type="primary"
:disabled="!selectedClientId || !clientUpdate.download_url"
@click="handleApplyClientUpdate"
>
<template #icon><n-icon><RocketOutline /></n-icon></template>
推送更新到客户端
</n-button>
</template>
</n-space>
</template>
</n-card>
</n-gi>
</n-grid>
</n-spin>
</div>
</template>
<style scoped>
.info-item {
display: flex;
flex-direction: column;
padding: 8px 0;
}
.info-item .label {
font-size: 12px;
color: #999;
margin-bottom: 4px;
}
.info-item .value {
font-size: 14px;
color: #333;
font-weight: 500;
}
</style>