Compare commits

..

44 Commits

Author SHA1 Message Date
sagit 20dc151aec Merge pull request #58 from Sagit-chu/opencode/calm-orchid
fix(backend): fix tunnel batch redeploy logic for type 2 tunnels and speed limit
2026-02-09 14:28:01 +08:00
sagit 630ed969d3 Merge branch 'main' into opencode/calm-orchid 2026-02-09 14:23:20 +08:00
sagit e94aa01213 fix(gost): append 'B' suffix to speed limit values for correct unit parsing 2026-02-09 06:22:47 +00:00
sagit 67d8f7a381 fix(backend): correct speed limit unit conversion from Mbps to Bytes/s 2026-02-09 06:13:56 +00:00
sagit 0c7b7deaf5 fix(backend): fix tunnel batch redeploy logic for type 2 tunnels 2026-02-09 05:17:58 +00:00
sagit a4def9c5f3 Merge pull request #57 from Sagit-chu/opencode/calm-orchid
fix: prevent nil pointer dereference in listener config parsing
2026-02-09 12:42:16 +08:00
sagit 6582348da2 Merge branch 'main' into opencode/calm-orchid 2026-02-09 12:40:57 +08:00
sagit 3a14b22ebc fix: prevent nil pointer dereference in listener config parsing 2026-02-09 04:39:27 +00:00
sagit d7b44916bf Merge pull request #56 from Sagit-chu/opencode/calm-orchid
fix(limiter): fix traffic limiter ScopeClient behavior to allow per-u…
2026-02-09 11:39:23 +08:00
sagit f8a0bda3fd Merge branch 'main' into opencode/calm-orchid 2026-02-09 11:37:49 +08:00
sagit 634562e56d fix(config): support raw number string for limiter configuration 2026-02-09 03:17:02 +00:00
sagit d06e02998b fix(limiter): fix traffic limiter ScopeClient behavior to allow per-user limits 2026-02-09 03:12:33 +00:00
sagit bbffe5872c Merge pull request #54 from Sagit-chu/opencode/clever-eagle
feat(backend): ensure all tables are created at startup
2026-02-09 09:42:48 +08:00
sagit 98db0e6a5e Merge branch 'main' into opencode/clever-eagle 2026-02-09 09:41:29 +08:00
sagit 30591a008a feat(backend): ensure all tables are created at startup 2026-02-09 01:29:42 +00:00
sagit 36ba2f95ef Merge pull request #53 from Sagit-chu/opencode/proud-knight
fix(docker): fix go-backend docker build syntax error
2026-02-08 20:12:44 +08:00
sagit 858f9ef1d5 fix(docker): fix go-backend docker build syntax error 2026-02-08 12:11:02 +00:00
sagit 218084b542 Merge pull request #51 from Sagit-chu/opencode/brave-meadow
feat: update doc
2026-02-08 19:57:42 +08:00
sagit a9c304546e Merge branch 'main' into opencode/brave-meadow 2026-02-08 19:55:57 +08:00
sagit 85250aa2d3 chore: fix remaining rebranding and frontend updates 2026-02-08 11:50:06 +00:00
sagit aeeb57b89d chore: rebrand to FLVX and fix docker multi-arch build 2026-02-08 11:50:06 +00:00
sagit 4e163163cb Merge pull request #50 from Sagit-chu/opencode/brave-meadow
docs: add usage documentation and github pages workflow
2026-02-08 18:54:50 +08:00
sagit eb5464511a Merge branch 'main' into opencode/brave-meadow 2026-02-08 18:53:05 +08:00
sagit efa6c0e322 docs: add usage documentation and github pages workflow 2026-02-08 10:51:18 +00:00
sagit 576654fc5a Merge pull request #48 from Sagit-chu/opencode/silent-orchid
fix(agent): handle config save errors and propagate to reporter
2026-02-08 16:27:48 +08:00
sagit a1454a3549 fix(agent): handle config save errors and propagate to reporter 2026-02-08 08:23:58 +00:00
sagit 1c72fb233a Merge pull request #46 from Sagit-chu/opencode/jolly-circuit
fix: fix some bugs
2026-02-08 15:28:28 +08:00
sagit ac1c2fa6bf Merge branch 'main' into opencode/jolly-circuit 2026-02-08 15:27:09 +08:00
sagit 4af2186e35 fix: 修复修改隧道负载策略不生效的问题 2026-02-08 07:25:35 +00:00
sagit 9fe9798677 fix: sync forward rules when updating user tunnel to preserve ports 2026-02-08 07:21:10 +00:00
sagit cfee6092c7 chore: delete springboot-backend directory 2026-02-08 07:15:47 +00:00
sagit 7ab6545594 fix: make chain and limiter updates idempotent
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-08 07:04:06 +00:00
sagit 2e2c182a0d fix: make service update idempotent (upsert)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-08 07:04:05 +00:00
sagit 96eec61a92 Merge pull request #45 from Sagit-chu/opencode/cosmic-sailor
fix(frontend): update turnstile implementation for login page
2026-02-08 14:54:32 +08:00
sagit 6e4bde4053 fix(frontend): update turnstile implementation for login page 2026-02-08 06:50:46 +00:00
sagit 33f347d1db Merge pull request #41 from Sagit-chu/opencode/proud-cactus
chore: remove obsolete root-level legacy Go files
2026-02-08 14:07:00 +08:00
sagit 17e5f4f95a Merge branch 'main' into opencode/proud-cactus 2026-02-08 14:05:43 +08:00
sagit b0304876a8 fix: implement random port assignment with conflict check for forward creation 2026-02-08 06:00:48 +00:00
sagit 2710cd1674 feat: replace legacy captcha with Cloudflare Turnstile 2026-02-08 04:51:41 +00:00
sagit a74eb9f431 fix: make service control robust against partial service absence 2026-02-08 04:17:10 +00:00
sagit d5c0060cd9 fix: resolve user_tunnel instability and service name drift 2026-02-08 03:09:38 +00:00
sagit 7f9c05172b fix: rollback forward mutation on tunnel switch failure 2026-02-08 02:30:36 +00:00
sagit d720b9e00c chore: remove mobile wrappers and harden go-backend service control 2026-02-08 01:59:45 +00:00
sagit 15cf25f74b chore: remove obsolete root-level legacy Go files 2026-02-07 15:08:23 +00:00
309 changed files with 1566 additions and 16287 deletions
+43
View File
@@ -0,0 +1,43 @@
name: Deploy Docs
on:
push:
branches:
- main
- master
- beta
paths:
- 'doc/**'
- 'mkdocs.yml'
permissions:
contents: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- uses: actions/setup-python@v5
with:
python-version: 3.x
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v4
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
- name: Install MkDocs and Material Theme
run: pip install mkdocs-material
- name: Build and Deploy
run: mkdocs gh-deploy --force
-42
View File
@@ -1,42 +0,0 @@
// Package controller provides HTTP request handlers and controllers for the 3x-ui web management panel.
// It handles routing, authentication, and API endpoints for managing Xray inbounds, settings, and more.
package controller
import (
"net/http"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/locale"
"github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-gonic/gin"
)
// BaseController provides common functionality for all controllers, including authentication checks.
type BaseController struct{}
// checkLogin is a middleware that verifies user authentication and handles unauthorized access.
func (a *BaseController) checkLogin(c *gin.Context) {
if !session.IsLogin(c) {
if isAjax(c) {
pureJsonMsg(c, http.StatusUnauthorized, false, I18nWeb(c, "pages.login.loginAgain"))
} else {
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
}
c.Abort()
} else {
c.Next()
}
}
// I18nWeb retrieves an internationalized message for the web interface based on the current locale.
func I18nWeb(c *gin.Context, name string, params ...string) string {
anyfunc, funcExists := c.Get("I18n")
if !funcExists {
logger.Warning("I18n function not exists in gin context!")
return ""
}
i18nFunc, _ := anyfunc.(func(i18nType locale.I18nType, key string, keyParams ...string) string)
msg := i18nFunc(locale.Web, name, params...)
return msg
}
-424
View File
@@ -1,424 +0,0 @@
package controller
import (
"encoding/json"
"fmt"
"strconv"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/mhsanaei/3x-ui/v2/web/websocket"
"github.com/gin-gonic/gin"
)
// InboundController handles HTTP requests related to Xray inbounds management.
type InboundController struct {
inboundService service.InboundService
xrayService service.XrayService
}
// NewInboundController creates a new InboundController and sets up its routes.
func NewInboundController(g *gin.RouterGroup) *InboundController {
a := &InboundController{}
a.initRouter(g)
return a
}
// initRouter initializes the routes for inbound-related operations.
func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.getInbounds)
g.GET("/get/:id", a.getInbound)
g.GET("/getClientTraffics/:email", a.getClientTraffics)
g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById)
g.POST("/add", a.addInbound)
g.POST("/del/:id", a.delInbound)
g.POST("/update/:id", a.updateInbound)
g.POST("/clientIps/:email", a.getClientIps)
g.POST("/clearClientIps/:email", a.clearClientIps)
g.POST("/addClient", a.addInboundClient)
g.POST("/:id/delClient/:clientId", a.delInboundClient)
g.POST("/updateClient/:clientId", a.updateInboundClient)
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
g.POST("/resetAllTraffics", a.resetAllTraffics)
g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics)
g.POST("/delDepletedClients/:id", a.delDepletedClients)
g.POST("/import", a.importInbound)
g.POST("/onlines", a.onlines)
g.POST("/lastOnline", a.lastOnline)
g.POST("/updateClientTraffic/:email", a.updateClientTraffic)
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
}
// getInbounds retrieves the list of inbounds for the logged-in user.
func (a *InboundController) getInbounds(c *gin.Context) {
user := session.GetLoginUser(c)
inbounds, err := a.inboundService.GetInbounds(user.Id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, inbounds, nil)
}
// getInbound retrieves a specific inbound by its ID.
func (a *InboundController) getInbound(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
inbound, err := a.inboundService.GetInbound(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, inbound, nil)
}
// getClientTraffics retrieves client traffic information by email.
func (a *InboundController) getClientTraffics(c *gin.Context) {
email := c.Param("email")
clientTraffics, err := a.inboundService.GetClientTrafficByEmail(email)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
return
}
jsonObj(c, clientTraffics, nil)
}
// getClientTrafficsById retrieves client traffic information by inbound ID.
func (a *InboundController) getClientTrafficsById(c *gin.Context) {
id := c.Param("id")
clientTraffics, err := a.inboundService.GetClientTrafficByID(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
return
}
jsonObj(c, clientTraffics, nil)
}
// addInbound creates a new inbound configuration.
func (a *InboundController) addInbound(c *gin.Context) {
inbound := &model.Inbound{}
err := c.ShouldBind(inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), err)
return
}
user := session.GetLoginUser(c)
inbound.UserId = user.Id
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
inbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
} else {
inbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port)
}
inbound, needRestart, err := a.inboundService.AddInbound(inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
// Broadcast inbounds update via WebSocket
inbounds, _ := a.inboundService.GetInbounds(user.Id)
websocket.BroadcastInbounds(inbounds)
}
// delInbound deletes an inbound configuration by its ID.
func (a *InboundController) delInbound(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), err)
return
}
needRestart, err := a.inboundService.DelInbound(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), id, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
// Broadcast inbounds update via WebSocket
user := session.GetLoginUser(c)
inbounds, _ := a.inboundService.GetInbounds(user.Id)
websocket.BroadcastInbounds(inbounds)
}
// updateInbound updates an existing inbound configuration.
func (a *InboundController) updateInbound(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
inbound := &model.Inbound{
Id: id,
}
err = c.ShouldBind(inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
inbound, needRestart, err := a.inboundService.UpdateInbound(inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), inbound, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
// Broadcast inbounds update via WebSocket
user := session.GetLoginUser(c)
inbounds, _ := a.inboundService.GetInbounds(user.Id)
websocket.BroadcastInbounds(inbounds)
}
// getClientIps retrieves the IP addresses associated with a client by email.
func (a *InboundController) getClientIps(c *gin.Context) {
email := c.Param("email")
ips, err := a.inboundService.GetInboundClientIps(email)
if err != nil || ips == "" {
jsonObj(c, "No IP Record", nil)
return
}
jsonObj(c, ips, nil)
}
// clearClientIps clears the IP addresses for a client by email.
func (a *InboundController) clearClientIps(c *gin.Context) {
email := c.Param("email")
err := a.inboundService.ClearClientIps(email)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
}
// addInboundClient adds a new client to an existing inbound.
func (a *InboundController) addInboundClient(c *gin.Context) {
data := &model.Inbound{}
err := c.ShouldBind(data)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
needRestart, err := a.inboundService.AddInboundClient(data)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
// delInboundClient deletes a client from an inbound by inbound ID and client ID.
func (a *InboundController) delInboundClient(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
clientId := c.Param("clientId")
needRestart, err := a.inboundService.DelInboundClient(id, clientId)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
// updateInboundClient updates a client's configuration in an inbound.
func (a *InboundController) updateInboundClient(c *gin.Context) {
clientId := c.Param("clientId")
inbound := &model.Inbound{}
err := c.ShouldBind(inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
needRestart, err := a.inboundService.UpdateInboundClient(inbound, clientId)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
// resetClientTraffic resets the traffic counter for a specific client in an inbound.
func (a *InboundController) resetClientTraffic(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
email := c.Param("email")
needRestart, err := a.inboundService.ResetClientTraffic(id, email)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
// resetAllTraffics resets all traffic counters across all inbounds.
func (a *InboundController) resetAllTraffics(c *gin.Context) {
err := a.inboundService.ResetAllTraffics()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
} else {
a.xrayService.SetToNeedRestart()
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
}
// resetAllClientTraffics resets traffic counters for all clients in a specific inbound.
func (a *InboundController) resetAllClientTraffics(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
err = a.inboundService.ResetAllClientTraffics(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
} else {
a.xrayService.SetToNeedRestart()
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
}
// importInbound imports an inbound configuration from provided data.
func (a *InboundController) importInbound(c *gin.Context) {
inbound := &model.Inbound{}
err := json.Unmarshal([]byte(c.PostForm("data")), inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
user := session.GetLoginUser(c)
inbound.Id = 0
inbound.UserId = user.Id
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
inbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
} else {
inbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port)
}
for index := range inbound.ClientStats {
inbound.ClientStats[index].Id = 0
inbound.ClientStats[index].Enable = true
}
needRestart := false
inbound, needRestart, err = a.inboundService.AddInbound(inbound)
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, err)
if err == nil && needRestart {
a.xrayService.SetToNeedRestart()
}
}
// delDepletedClients deletes clients in an inbound who have exhausted their traffic limits.
func (a *InboundController) delDepletedClients(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
err = a.inboundService.DelDepletedClients(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.delDepletedClientsSuccess"), nil)
}
// onlines retrieves the list of currently online clients.
func (a *InboundController) onlines(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
}
// lastOnline retrieves the last online timestamps for clients.
func (a *InboundController) lastOnline(c *gin.Context) {
data, err := a.inboundService.GetClientsLastOnline()
jsonObj(c, data, err)
}
// updateClientTraffic updates the traffic statistics for a client by email.
func (a *InboundController) updateClientTraffic(c *gin.Context) {
email := c.Param("email")
// Define the request structure for traffic update
type TrafficUpdateRequest struct {
Upload int64 `json:"upload"`
Download int64 `json:"download"`
}
var request TrafficUpdateRequest
err := c.ShouldBindJSON(&request)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
err = a.inboundService.UpdateClientTrafficByEmail(email, request.Upload, request.Download)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
}
// delInboundClientByEmail deletes a client from an inbound by email address.
func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
inboundId, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid inbound ID", err)
return
}
email := c.Param("email")
needRestart, err := a.inboundService.DelInboundClientByEmail(inboundId, email)
if err != nil {
jsonMsg(c, "Failed to delete client by email", err)
return
}
jsonMsg(c, "Client deleted successfully", nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
-54
View File
@@ -1,54 +0,0 @@
package controller
import (
"github.com/gin-gonic/gin"
)
// XUIController is the main controller for the X-UI panel, managing sub-controllers.
type XUIController struct {
BaseController
settingController *SettingController
xraySettingController *XraySettingController
}
// NewXUIController creates a new XUIController and initializes its routes.
func NewXUIController(g *gin.RouterGroup) *XUIController {
a := &XUIController{}
a.initRouter(g)
return a
}
// initRouter sets up the main panel routes and initializes sub-controllers.
func (a *XUIController) initRouter(g *gin.RouterGroup) {
g = g.Group("/panel")
g.Use(a.checkLogin)
g.GET("/", a.index)
g.GET("/inbounds", a.inbounds)
g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings)
a.settingController = NewSettingController(g)
a.xraySettingController = NewXraySettingController(g)
}
// index renders the main panel index page.
func (a *XUIController) index(c *gin.Context) {
html(c, "index.html", "pages.index.title", nil)
}
// inbounds renders the inbounds management page.
func (a *XUIController) inbounds(c *gin.Context) {
html(c, "inbounds.html", "pages.inbounds.title", nil)
}
// settings renders the settings management page.
func (a *XUIController) settings(c *gin.Context) {
html(c, "settings.html", "pages.settings.title", nil)
}
// xraySettings renders the Xray settings page.
func (a *XUIController) xraySettings(c *gin.Context) {
html(c, "xray.html", "pages.xray.title", nil)
}
+15 -11
View File
@@ -5,17 +5,15 @@
**Branch:** beta
## OVERVIEW
Flux Panel is a traffic forwarding management system built on a forked GOST v3 stack. It ships as Dockerized Spring Boot (admin API) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
## STRUCTURE
```
./
├── go-gost/ # Go forwarding agent (forked gost + local x/)
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
├── springboot-backend/ # Java/Spring Boot admin API (SQLite/MyBatis)
├── go-backend/ # Go Admin API (SQLite, net/http)
├── vite-frontend/ # React/Vite dashboard (HeroUI + Tailwind)
├── android-app/ # Android WebView wrapper (optional)
├── ios-app/ # iOS WebView wrapper (optional)
├── docker-compose-v4.yml # Panel deploy (IPv4-only bridge)
├── docker-compose-v6.yml # Panel deploy (IPv6-enabled bridge)
├── panel_install.sh # Panel installer/upgrader (downloads compose)
@@ -30,13 +28,19 @@ Flux Panel is a traffic forwarding management system built on a forked GOST v3 s
| **Deploy (IPv6)** | `docker-compose-v6.yml` | Same as v4 + IPv6-enabled bridge |
| **Panel install** | `panel_install.sh` | Picks v4/v6, generates `JWT_SECRET`, downloads compose |
| **Node install** | `install.sh` | Installs `/etc/flux_agent/flux_agent` + writes `config.json`/`gost.json` + systemd `flux_agent.service` |
| **Admin API entry** | `springboot-backend/src/main/java/com/admin/AdminApplication.java` | Spring Boot app |
| **Admin API routes** | `springboot-backend/src/main/java/com/admin/controller/` | Mostly `/api/v1/*` controllers |
| **Admin auth** | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Checks `Authorization` header |
| **Web UI routing** | `vite-frontend/src/App.tsx` | React Router v6 + ProtectedRoute/H5 layouts |
| **Web UI API client** | `vite-frontend/src/api/network.ts` | Axios `baseURL` + `Authorization` header |
| **Go agent entry** | `go-gost/main.go` | Reads panel `config.json` + starts gost services |
| **Go x fork** | `go-gost/x/` | Handlers/listeners/dialers + management API |
| **Admin API** | `go-backend/` | Go Admin API (SQLite) |
| **Web UI** | `vite-frontend/` | React/Vite dashboard (HeroUI + Tailwind) |
| **Go Agent** | `go-gost/` | Forwarding agent (forked gost + local x/) |
| **Go Core** | `go-gost/x/` | Handlers/listeners/dialers + management API |
## CODE MAP
| Symbol | Type | Location | Role |
|--------|------|----------|------|
| `flvx` | Project | `.` | Root directory |
| `main` | Func | `go-backend/cmd/paneld/main.go` | Backend Entry |
| `App` | Component | `vite-frontend/src/App.tsx` | Frontend Entry |
| `main` | Func | `go-gost/main.go` | Agent Entry |
## CONVENTIONS
- `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `springboot-backend/`.
+3 -1
View File
@@ -1,4 +1,6 @@
# flux-panel转发面板 哆啦A梦转发面板
# FLVX
> 📞 **联系我们**: [Telegram群组](https://t.me/flvxpanel)
本项目基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 两个开源库,实现了转发面板。
---
-15
View File
@@ -1,15 +0,0 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
-1
View File
@@ -1 +0,0 @@
/build
-45
View File
@@ -1,45 +0,0 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'com.flux'
compileSdk 34
defaultConfig {
applicationId "com.flux"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'androidx.webkit:webkit:1.8.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
}
-21
View File
@@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
Binary file not shown.
@@ -1,20 +0,0 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.flux",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0.1",
"outputFile": "app-release.apk"
}
],
"elementType": "File"
}
@@ -1,24 +0,0 @@
package com.flux
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.flux", appContext.packageName)
}
}
@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.flux" >
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Flux"
android:usesCleartextTraffic="true"
tools:targetApi="31" >
<activity
android:name="com.flux.MainActivity"
android:exported="true"
android:configChanges="uiMode|orientation|screenSize" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

@@ -1,214 +0,0 @@
package com.flux
import androidx.core.content.edit
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowInsetsController
import androidx.appcompat.app.AppCompatActivity
import android.webkit.*
import android.content.res.Configuration
import org.json.JSONArray
import org.json.JSONObject
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
webView = findViewById(R.id.webView)
WebView.setWebContentsDebuggingEnabled(true)
setupWebView()
loadUrl()
setupSystemBars()
setupRootBackground()
}
private fun setupWebView() {
val settings = webView.settings
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.databaseEnabled = true
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
settings.setSupportZoom(true)
settings.builtInZoomControls = true
settings.displayZoomControls = false
// 隐藏滚动条但不影响滚动
webView.isVerticalScrollBarEnabled = false
webView.isHorizontalScrollBarEnabled = false
webView.scrollBarStyle = View.SCROLLBARS_INSIDE_OVERLAY
webView.overScrollMode = View.OVER_SCROLL_NEVER
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
}
val isDarkTheme = resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
webView.setBackgroundColor(if (isDarkTheme) Color.BLACK else Color.WHITE)
// 添加JavaScript接口
webView.addJavascriptInterface(object {
@android.webkit.JavascriptInterface
fun getPanelAddresses(callback: String) {
val sharedPrefs = getSharedPreferences("panel_config", MODE_PRIVATE)
val addresses = sharedPrefs.getString("panel_addresses", "[]")
webView.post {
webView.evaluateJavascript("window.$callback($addresses);", null)
}
}
@android.webkit.JavascriptInterface
fun savePanelAddress(name: String, address: String) {
val json = JSONObject()
json.put("name", name)
json.put("address", address)
json.put("inx", false)
val sharedPrefs = getSharedPreferences("panel_config", MODE_PRIVATE)
val addresses = sharedPrefs.getString("panel_addresses", "[]")
val jsonArray = JSONArray(addresses)
jsonArray.put(json)
sharedPrefs.edit {
putString("panel_addresses", jsonArray.toString())
}
webView.post {
webView.evaluateJavascript("window.setPanelAddresses(${jsonArray.toString()});", null)
}
}
@android.webkit.JavascriptInterface
fun setCurrentPanelAddress(name: String) {
val sharedPrefs = getSharedPreferences("panel_config", MODE_PRIVATE)
val addresses = sharedPrefs.getString("panel_addresses", "[]")
val jsonArray = JSONArray(addresses)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
if (name == obj.getString("name")) {
obj.put("inx", true)
}else{
obj.put("inx", false)
}
}
sharedPrefs.edit {
putString("panel_addresses", jsonArray.toString())
}
webView.post {
webView.evaluateJavascript("window.setPanelAddresses(${jsonArray.toString()});", null)
}
}
@android.webkit.JavascriptInterface
fun deletePanelAddress(name: String) {
val jsonArray = JSONArray()
val sharedPrefs = getSharedPreferences("panel_config", MODE_PRIVATE)
val addresses = sharedPrefs.getString("panel_addresses", "[]")
val jsonArraya = JSONArray(addresses)
for (i in 0 until jsonArraya.length()) {
val obj = jsonArraya.getJSONObject(i)
if (name != obj.getString("name")) {
jsonArray.put(obj)
}
}
sharedPrefs.edit {
putString("panel_addresses", jsonArray.toString())
}
webView.post {
webView.evaluateJavascript("window.setPanelAddresses(${jsonArray.toString()});", null)
}
}
}, "JsInterface")
}
private fun loadUrl() {
//webView.loadUrl("http://192.168.100.9:3000")
webView.loadUrl("file:///android_asset/index.html")
}
private fun setupSystemBars() {
val isDarkTheme = resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
if (isDarkTheme) {
window.statusBarColor = Color.BLACK
window.navigationBarColor = Color.BLACK
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.insetsController?.setSystemBarsAppearance(
0,
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS or
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS
)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility =
window.decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility =
window.decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv()
}
}
} else {
window.statusBarColor = Color.WHITE
window.navigationBarColor = Color.WHITE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.insetsController?.setSystemBarsAppearance(
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS or
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS,
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS or
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS
)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility =
window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility =
window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
}
}
}
}
override fun onBackPressed() {
if (webView.canGoBack()) webView.goBack() else super.onBackPressed()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
setupSystemBars()
setupWebViewBackground()
setupRootBackground()
}
private fun setupWebViewBackground() {
val isDarkTheme = resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
webView.setBackgroundColor(if (isDarkTheme) Color.BLACK else Color.WHITE)
}
private fun setupRootBackground() {
val isDarkTheme = resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
val rootView = findViewById<View>(android.R.id.content)
rootView.setBackgroundColor(if (isDarkTheme) Color.BLACK else Color.WHITE)
}
}
@@ -1,74 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>
@@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:id="@+id/webView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

@@ -1,9 +0,0 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.Flux" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your dark theme here. -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -1,3 +0,0 @@
<resources>
<string name="app_name">Flux</string>
</resources>
@@ -1,11 +0,0 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.Flux" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<style name="Theme.Flux" parent="Base.Theme.Flux" />
</resources>
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -1,17 +0,0 @@
package com.flux
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
-5
View File
@@ -1,5 +0,0 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.2.2' apply false
id 'org.jetbrains.kotlin.android' version '1.8.0' apply false
}
-23
View File
@@ -1,23 +0,0 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
-6
View File
@@ -1,6 +0,0 @@
#Wed Aug 20 09:15:27 CST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-185
View File
@@ -1,185 +0,0 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
-89
View File
@@ -1,89 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
-17
View File
@@ -1,17 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Flux"
include ':app'
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

+24
View File
@@ -0,0 +1,24 @@
# 常见问题 (FAQ)
### Q1: 安装脚本提示 "Docker command not found"?
**A**: 请确保您的系统已安装 Docker 和 Docker Compose。
- Ubuntu/Debian 安装 Docker: `curl -fsSL https://get.docker.com | bash`
### Q2: 面板无法访问 (Connection Refused)?
**A**:
1. 检查防火墙是否放行了前端端口(默认 `6366`)。
2. 检查容器是否正常运行: `docker ps`。
3. 查看容器日志: `docker logs flux-panel-backend` 或 `docker logs vite-frontend`。
### Q3: 节点显示离线?
**A**:
1. 检查节点服务器与面板服务器之间的网络连通性。
2. 确认在节点端安装时输入的 **面板地址** 和 **密钥** 是否正确。
3. 检查节点端服务状态: `systemctl status flux_agent`。
4. 查看节点端日志: `journalctl -u flux_agent -f`。
### Q4: 只有 TCP 能通,UDP 不通?
**A**: 请检查服务器防火墙和安全组(AWS/阿里云/腾讯云等)是否同时放行了对应端口的 **TCP 和 UDP** 协议。
### Q5: IPv6 无法使用?
**A**: 面板安装脚本会自动尝试配置 Docker 的 IPv6。如果失败,请手动检查 `/etc/docker/daemon.json` 配置,确保 `ipv6: true` 且分配了正确的 `fixed-cidr-v6` 子网。
+37
View File
@@ -0,0 +1,37 @@
# FLVX 官方文档
**FLVX** 是一个基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 开发的高性能流量转发管理系统。
> 📞 **联系我们**: [Telegram群组](https://t.me/flvxpanel)
## 核心特性
- **多协议支持**: 完美支持 TCP 和 UDP 协议转发。
- **灵活转发**: 支持 **端口转发** 与 **隧道转发** 两种模式。
- **流量控制**: 支持按 **隧道账号级别** 管理流量转发数量,用于用户/隧道配额控制。
- **限速管理**: 可针对 **指定用户的指定隧道进行限速** 设置。
- **计费策略**: 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型。
- **策略配置**: 提供灵活的转发策略配置,适用于多种网络场景。
## 快速开始
- [安装部署](./install.md)
- [使用指南](./usage.md)
- [常见问题](./faq.md)
## 免责声明
本项目仅供个人学习与研究使用,基于开源项目进行二次开发。
使用本项目所带来的任何风险均由使用者自行承担。本项目为开源的流量转发工具,仅限合法、合规用途。
**禁止将本项目用于任何违法或未经授权的行为,包括但不限于网络攻击、数据窃取、非法访问等。**
## 捐赠支持
如果您觉得本项目对您有帮助,欢迎请作者喝杯咖啡!
| 网络 | 地址 |
|------------|----------------------------------------------------------------------|
| BNB(BEP20) | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
| TRC20 | `TM8VYdU3s3gSX5PC8swjAJrAzZFCHKqG2k` |
| Aptos | `0x49427bfcba1006a346447430689b2307ac156316bb34850d1d3029ff9d118da5` |
| Polygon | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
+79
View File
@@ -0,0 +1,79 @@
# 安装部署指南
本文档介绍如何部署 FLVX 面板端及节点端。
## 一、面板端部署
面板端负责管理用户、节点和转发规则。
### 1. 环境要求
- 操作系统:Linux (推荐 Debian 10+ / Ubuntu 20.04+)
- 必须安装 Docker 和 Docker Compose
### 2. 一键安装脚本
使用以下命令即可快速安装面板:
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
**安装过程中会提示输入以下信息:**
- **前端端口**: 默认为 `6366`
- **后端端口**: 默认为 `6365`
脚本会自动检测系统是否支持 IPv6,并自动配置 Docker 的 IPv6 支持。
### 3. 访问面板
安装完成后,访问:
`http://<服务器IP>:<前端端口>` (默认: `http://<服务器IP>:6366`)
**默认管理员账号:**
- 用户名: `admin_user`
- 密码: `admin_user`
> ⚠️ **注意**: 首次登录后,请务必在“个人中心”或“设置”中修改默认密码!
### 4. 维护命令
再次运行 `./panel_install.sh` 脚本可以看到管理菜单:
1. 安装面板
2. 更新面板
3. 卸载面板
---
## 二、节点端部署
节点端运行在实际进行流量转发的服务器上,需要连接到面板端进行管理。
### 1. 获取接入密钥
1. 登录面板端。
2. 进入 **节点管理 (Node)** 页面。
3. 点击 **添加节点**。
4. 获取该节点的 **接入密钥 (Secret)**。
### 2. 一键安装脚本
在节点服务器上运行:
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
**安装过程中会提示输入:**
- **服务器地址**: 面板端的通信地址(通常是 `http://<面板IP>:<后端端口>`,例如 `http://1.2.3.4:6365`)。
- **密钥**: 刚才在面板中获取的节点密钥。
或者直接使用带参数的命令(适用于自动化部署):
```bash
# 替换 <面板地址> 和 <密钥>
./install.sh -a "http://1.2.3.4:6365" -s "your_node_secret"
```
### 3. 验证安装
安装完成后,服务会自动启动。
- 查看状态: `systemctl status flux_agent`
- 回到面板 **节点管理** 页面,该节点状态应显示为 **在线**。
+35
View File
@@ -0,0 +1,35 @@
# 使用指南
## 1. 仪表盘 (Dashboard)
登录系统后首先看到的是仪表盘,这里展示了系统的整体运行状态,包括:
- 在线节点数量
- 用户总数
- 流量统计信息
## 2. 节点管理 (Node)
节点是实际承载流量转发的服务器。
- **添加节点**: 点击“添加”,获取密钥用于节点端安装。
- **管理**: 可以查看节点在线状态、版本信息,以及对节点进行编辑或删除。
## 3. 用户管理 (User)
管理员可以创建和管理普通用户。
- **创建用户**: 设置用户名、密码、流量配额等。
- **用户组**: 可以将用户分配到不同的组 (Group),便于统一管理权限或策略。
## 4. 转发管理 (Forward)
这是核心功能区,用于设置端口转发规则。
- **端口转发**: 将节点服务器的某个端口流量转发到目标地址。
- **协议**: 支持 TCP / UDP。
- **入口**: 选择入口节点和监听端口。
- **出口**: 设置目标 IP 和端口。
- **隧道转发**: 用于更复杂的网络穿透场景(具体配置视业务需求而定)。
## 5. 限制与策略 (Limit)
- **限速**: 可以对指定用户或指定隧道进行带宽限制,防止资源滥用。
- **计费模式**: 支持配置流量计算方式(单向或双向),适合运营场景。
## 6. 系统配置 (Config)
在此页面进行系统的全局设置。
## 7. 个人设置 (Profile)
- **修改密码**: 为了安全,建议定期修改管理员密码。
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

BIN
View File
Binary file not shown.
+47
View File
@@ -0,0 +1,47 @@
# GO BACKEND KNOWLEDGE BASE
## OVERVIEW
Go-based Admin API for FLVX (formerly Flux Panel). Replaces the legacy Spring Boot backend.
**Stack:** Go 1.23, net/http (std lib), SQLite (modernc.org/sqlite).
## STRUCTURE
```
go-backend/
├── cmd/paneld/main.go # Entry point; starts HTTP server + WebSocket
├── internal/
│ ├── http/ # HTTP layer
│ │ ├── router.go # Routes (NewServeMux) + Middleware chain
│ │ ├── handler/ # API Handlers (User, Tunnel, Node, etc.)
│ │ ├── middleware/ # JWT, CORS, Logging, Recover
│ │ └── response/ # JSON response helpers
│ ├── store/sqlite/ # Data Access Layer (Repository pattern)
│ │ ├── repository.go # SQL queries & Struct definitions
│ │ └── sql/ # Embedded schema.sql & data.sql
│ └── auth/ # Auth logic
├── tests/ # Integration/Contract tests
├── Dockerfile # Multi-stage build (alpine)
└── Makefile # Build commands
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **API Routes** | `go-backend/internal/http/router.go` | Registers handlers to `http.ServeMux` |
| **DB Schema** | `go-backend/internal/store/sqlite/sql/schema.sql` | Embedded in binary |
| **SQL Queries** | `go-backend/internal/store/sqlite/repository.go` | Raw SQL, no ORM |
| **Auth Middleware** | `go-backend/internal/http/middleware/jwt.go` | Extracts `Authorization` header |
| **WebSocket** | `go-backend/internal/ws/` | Real-time updates (traffic, status) |
## CONVENTIONS
- **No ORM**: Uses raw SQL with `database/sql` and `modernc.org/sqlite`.
- **Standard Lib**: Uses `net/http` for routing (Go 1.22+ patterns).
- **Auth**: Expects raw JWT in `Authorization` header (no `Bearer` prefix).
- **Config**: Loaded from environment variables (see `cmd/paneld/main.go`).
## COMMANDS
```bash
cd go-backend
go run ./cmd/paneld
go test ./...
make build
```
+3 -1
View File
@@ -5,7 +5,9 @@ COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/paneld ./cmd/paneld
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -o /out/paneld ./cmd/paneld
FROM debian:bookworm-slim
WORKDIR /app
@@ -1,67 +0,0 @@
package handler
import "time"
const captchaTokenTTL = 5 * time.Minute
func (h *Handler) storeCaptchaToken(token string) {
if h == nil {
return
}
token = normalizeCaptchaToken(token)
if token == "" {
return
}
h.captchaMu.Lock()
defer h.captchaMu.Unlock()
now := time.Now().UnixMilli()
h.pruneExpiredCaptchaTokensLocked(now)
h.captchaTokens[token] = now + int64(captchaTokenTTL/time.Millisecond)
}
func (h *Handler) consumeCaptchaToken(token string) bool {
if h == nil {
return false
}
token = normalizeCaptchaToken(token)
if token == "" {
return false
}
h.captchaMu.Lock()
defer h.captchaMu.Unlock()
now := time.Now().UnixMilli()
h.pruneExpiredCaptchaTokensLocked(now)
expiresAt, ok := h.captchaTokens[token]
if !ok || expiresAt <= now {
delete(h.captchaTokens, token)
return false
}
delete(h.captchaTokens, token)
return true
}
func (h *Handler) pruneExpiredCaptchaTokensLocked(now int64) {
for token, expiresAt := range h.captchaTokens {
if expiresAt <= now {
delete(h.captchaTokens, token)
}
}
}
func normalizeCaptchaToken(token string) string {
return trimToken(token)
}
func trimToken(token string) string {
for len(token) > 0 && (token[0] == ' ' || token[0] == '\t' || token[0] == '\n' || token[0] == '\r') {
token = token[1:]
}
for len(token) > 0 && (token[len(token)-1] == ' ' || token[len(token)-1] == '\t' || token[len(token)-1] == '\n' || token[len(token)-1] == '\r') {
token = token[:len(token)-1]
}
return token
}
@@ -59,6 +59,8 @@ type chainNodeRecord struct {
NodeID int64
Port int
NodeName string
Protocol string
Strategy string
}
type diagnosisTarget struct {
@@ -238,6 +240,7 @@ func (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *i
FROM user_tunnel ut
LEFT JOIN speed_limit sl ON sl.id = ut.speed_id
WHERE ut.user_id = ? AND ut.tunnel_id = ?
ORDER BY ut.id ASC
LIMIT 1
`, userID, tunnelID)
var userTunnelID int64
@@ -256,6 +259,58 @@ func (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *i
return userTunnelID, &v, nil
}
func (h *Handler) listUserTunnelIDs(userID, tunnelID int64) ([]int64, error) {
rows, err := h.repo.DB().Query(`
SELECT id
FROM user_tunnel
WHERE user_id = ? AND tunnel_id = ?
ORDER BY id ASC
`, userID, tunnelID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]int64, 0)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (h *Handler) listUserTunnelIDsByUser(userID int64) ([]int64, error) {
rows, err := h.repo.DB().Query(`
SELECT id
FROM user_tunnel
WHERE user_id = ?
ORDER BY id ASC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]int64, 0)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (h *Handler) syncForwardServices(forward *forwardRecord, method string, allowFallbackAdd bool) error {
if h == nil || forward == nil {
return errors.New("invalid forward sync context")
@@ -311,20 +366,66 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
if err != nil {
return err
}
base := buildForwardServiceBase(forward.ID, forward.UserID, userTunnelID)
payload := map[string]interface{}{
"services": buildForwardControlServiceNames(base, commandType),
userTunnelIDs, err := h.listUserTunnelIDs(forward.UserID, forward.TunnelID)
if err != nil {
return err
}
allUserTunnelIDs, err := h.listUserTunnelIDsByUser(forward.UserID)
if err != nil {
return err
}
candidateTunnelIDs := make([]int64, 0, len(userTunnelIDs)+len(allUserTunnelIDs))
candidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
seen := map[int64]struct{}{}
for _, fp := range ports {
if _, ok := seen[fp.NodeID]; ok {
continue
}
seen[fp.NodeID] = struct{}{}
_, err := h.sendNodeCommand(fp.NodeID, commandType, payload, false, tolerateNotFound)
if err != nil {
return err
var lastNotFoundErr error
nodeHandled := false
for _, base := range bases {
variants := []string{base + "_tcp", base + "_udp"}
if shouldTryLegacySingleService(commandType) || strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
variants = append(variants, base)
}
candidateHandled := false
for _, name := range variants {
payload := map[string]interface{}{
"services": []string{name},
}
_, err := h.sendNodeCommand(fp.NodeID, commandType, payload, false, false)
if err == nil {
candidateHandled = true
continue
}
if !isNotFoundError(err) {
return err
}
lastNotFoundErr = err
}
if candidateHandled {
nodeHandled = true
break
}
}
if nodeHandled {
continue
}
if tolerateNotFound {
continue
}
if lastNotFoundErr != nil {
return lastNotFoundErr
}
return errors.New("service control failed")
}
return nil
}
@@ -763,7 +864,7 @@ func firstPortFromRange(portRange string) int {
func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, error) {
rows, err := h.repo.DB().Query(`
SELECT ct.chain_type, COALESCE(ct.inx, 0), ct.node_id, COALESCE(ct.port, 0), n.name
SELECT ct.chain_type, COALESCE(ct.inx, 0), ct.node_id, COALESCE(ct.port, 0), n.name, ct.protocol, ct.strategy
FROM chain_tunnel ct
LEFT JOIN node n ON n.id = ct.node_id
WHERE ct.tunnel_id = ?
@@ -778,7 +879,9 @@ func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, er
for rows.Next() {
var item chainNodeRecord
var name sql.NullString
if err := rows.Scan(&item.ChainType, &item.Inx, &item.NodeID, &item.Port, &name); err != nil {
var protocol sql.NullString
var strategy sql.NullString
if err := rows.Scan(&item.ChainType, &item.Inx, &item.NodeID, &item.Port, &name, &protocol, &strategy); err != nil {
return nil, err
}
if strings.TrimSpace(name.String) == "" {
@@ -786,6 +889,8 @@ func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, er
} else {
item.NodeName = name.String
}
item.Protocol = defaultString(protocol.String, "tls")
item.Strategy = defaultString(strategy.String, "round")
result = append(result, item)
}
if err := rows.Err(); err != nil {
@@ -852,6 +957,31 @@ func buildForwardServiceBase(forwardID, userID, userTunnelID int64) string {
return fmt.Sprintf("%d_%d_%d", forwardID, userID, userTunnelID)
}
func buildForwardServiceBaseCandidates(forwardID, userID, preferredUserTunnelID int64, userTunnelIDs []int64) []string {
orderedIDs := make([]int64, 0, len(userTunnelIDs)+2)
seen := make(map[int64]struct{}, len(userTunnelIDs)+2)
appendID := func(id int64) {
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
orderedIDs = append(orderedIDs, id)
}
appendID(preferredUserTunnelID)
for _, id := range userTunnelIDs {
appendID(id)
}
appendID(0)
bases := make([]string, 0, len(orderedIDs))
for _, id := range orderedIDs {
bases = append(bases, buildForwardServiceBase(forwardID, userID, id))
}
return bases
}
func buildForwardControlServiceNames(base, commandType string) []string {
names := []string{base + "_tcp", base + "_udp"}
if strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
@@ -860,6 +990,19 @@ func buildForwardControlServiceNames(base, commandType string) []string {
return names
}
func shouldTryLegacySingleService(commandType string) bool {
cmd := strings.ToLower(strings.TrimSpace(commandType))
return cmd == "pauseservice" || cmd == "resumeservice"
}
func isNotFoundError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(strings.TrimSpace(err.Error()))
return strings.Contains(msg, "not found") || strings.Contains(msg, "不存在")
}
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiter *int) []map[string]interface{} {
protocols := []string{"tcp", "udp"}
services := make([]map[string]interface{}, 0, 2)
@@ -902,7 +1045,10 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
service["metadata"] = map[string]interface{}{"interface": node.InterfaceName}
}
if limiter != nil && *limiter > 0 {
service["limiter"] = strconv.Itoa(*limiter)
// Convert Mbps to Bytes/s
// 1 Mbps = 1,000,000 bits/s = 125,000 Bytes/s
// We use decimal Mbps standard as is common in networking
service["limiter"] = strconv.Itoa(*limiter * 125000)
}
services = append(services, service)
}
@@ -25,3 +25,31 @@ func TestBuildForwardControlServiceNamesDelete(t *testing.T) {
t.Fatalf("expected %v, got %v", want, got)
}
}
func TestBuildForwardServiceBaseCandidates(t *testing.T) {
got := buildForwardServiceBaseCandidates(12, 34, 56, []int64{56, 78, 90})
want := []string{"12_34_56", "12_34_78", "12_34_90", "12_34_0"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
}
func TestBuildForwardServiceBaseCandidatesWithZeroPreferred(t *testing.T) {
got := buildForwardServiceBaseCandidates(12, 34, 0, []int64{78, 0, 90})
want := []string{"12_34_0", "12_34_78", "12_34_90"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
}
func TestShouldTryLegacySingleService(t *testing.T) {
if !shouldTryLegacySingleService("PauseService") {
t.Fatalf("PauseService should require legacy fallback")
}
if !shouldTryLegacySingleService("resumeService") {
t.Fatalf("ResumeService should require legacy fallback")
}
if shouldTryLegacySingleService("DeleteService") {
t.Fatalf("DeleteService should not require legacy fallback")
}
}
+41 -16
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
@@ -26,9 +27,6 @@ type Handler struct {
jwtSecret string
wsServer *ws.Server
captchaMu sync.Mutex
captchaTokens map[string]int64
jobsMu sync.Mutex
jobsCancel context.CancelFunc
jobsStarted bool
@@ -65,10 +63,9 @@ type flowItem struct {
func New(repo *sqlite.Repository, jwtSecret string) *Handler {
return &Handler{
repo: repo,
jwtSecret: jwtSecret,
wsServer: ws.NewServer(repo, jwtSecret),
captchaTokens: make(map[string]int64),
repo: repo,
jwtSecret: jwtSecret,
wsServer: ws.NewServer(repo, jwtSecret),
}
}
@@ -88,8 +85,6 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
mux.HandleFunc("/api/v1/captcha/check", h.checkCaptcha)
mux.HandleFunc("/api/v1/captcha/generate", h.captchaGenerate)
mux.HandleFunc("/api/v1/captcha/verify", h.captchaVerify)
mux.HandleFunc("/api/v1/user/package", h.userPackage)
mux.HandleFunc("/api/v1/user/updatePassword", h.updatePassword)
mux.HandleFunc("/api/v1/node/list", h.nodeList)
@@ -181,13 +176,22 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if captchaEnabled && strings.TrimSpace(req.CaptchaID) == "" {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
return
}
if captchaEnabled && !h.consumeCaptchaToken(req.CaptchaID) {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
return
if captchaEnabled {
if strings.TrimSpace(req.CaptchaID) == "" {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
return
}
secretCfg, err := h.repo.GetConfigByName("cloudflare_secret_key")
if err != nil || secretCfg == nil || secretCfg.Value == "" {
response.WriteJSON(w, response.ErrDefault("验证码配置错误:未配置Secret Key"))
return
}
if !h.verifyCloudflareTurnstile(req.CaptchaID, secretCfg.Value) {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
return
}
}
user, err := h.repo.GetUserByUsername(req.Username)
@@ -961,3 +965,24 @@ func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
}
return string(plain), nil
}
func (h *Handler) verifyCloudflareTurnstile(token, secretKey string) bool {
if token == "" || secretKey == "" {
return false
}
resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{
"secret": {secretKey},
"response": {token},
})
if err != nil {
return false
}
defer resp.Body.Close()
var body struct {
Success bool `json:"success"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return false
}
return body.Success
}
+447 -99
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"math/big"
"net"
"net/http"
"sort"
@@ -251,58 +252,6 @@ func (h *Handler) userResetFlow(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) captchaGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"success":false,"message":"bad request"}`))
return
}
token := randomToken(16)
payload := map[string]interface{}{
"id": token,
"data": map[string]interface{}{
"id": token,
},
"success": true,
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(payload)
}
func (h *Handler) captchaVerify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"success":false,"message":"bad request"}`))
return
}
var req map[string]interface{}
if err := decodeJSON(r.Body, &req); err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"success":false,"message":"bad request"}`))
return
}
id := asString(req["captchaId"])
if id == "" {
id = asString(req["id"])
}
trackData := asString(req["data"])
if trackData == "" {
trackData = asString(req["trackData"])
}
if id == "" || trackData == "" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"success":false,"message":"bad request"}`))
return
}
h.storeCaptchaToken(id)
payload := map[string]interface{}{
"success": true,
"data": map[string]interface{}{"validToken": id},
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(payload)
}
func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -589,6 +538,31 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) cleanupTunnelRuntime(tunnelID int64) {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil || tunnel.Type != 2 {
return
}
chainRows, err := h.listChainNodesForTunnel(tunnelID)
if err != nil {
return
}
serviceName := fmt.Sprintf("%d_tls", tunnelID)
chainName := fmt.Sprintf("chains_%d", tunnelID)
for _, row := range chainRows {
if row.ChainType == 1 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
} else if row.ChainType == 2 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
} else if row.ChainType == 3 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
}
}
}
func (h *Handler) tunnelGet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -627,19 +601,34 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
return
}
h.cleanupTunnelRuntime(id)
now := time.Now().UnixMilli()
_, err := h.repo.DB().Exec(`UPDATE tunnel SET name=?, type=?, flow=?, traffic_ratio=?, status=?, in_ip=?, updated_time=? WHERE id=?`,
asString(req["name"]), asInt(req["type"], 1), asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1), nullableText(asString(req["inIp"])), now, id)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
typeVal := asInt(req["type"], 1)
tx, err := h.repo.DB().Begin()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
defer func() { _ = tx.Rollback() }()
runtimeState, err := h.prepareTunnelCreateState(tx, req, typeVal)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
runtimeState.TunnelID = id
applyTunnelPortsToRequest(req, runtimeState)
_, err = tx.Exec(`UPDATE tunnel SET name=?, type=?, flow=?, traffic_ratio=?, status=?, in_ip=?, updated_time=? WHERE id=?`,
asString(req["name"]), typeVal, asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1), nullableText(asString(req["inIp"])), now, id)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if _, err := tx.Exec(`DELETE FROM chain_tunnel WHERE tunnel_id = ?`, id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -652,6 +641,16 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if typeVal == 2 {
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
if applyErr != nil {
h.rollbackTunnelRuntime(createdChains, createdServices, id)
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
return
}
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -664,6 +663,7 @@ func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
if id <= 0 {
return
}
h.cleanupTunnelRuntime(id)
if err := h.deleteTunnelByID(id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -721,6 +721,7 @@ func (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {
success := 0
fail := 0
for _, id := range ids {
h.cleanupTunnelRuntime(id)
if err := h.deleteTunnelByID(id); err != nil {
fail++
} else {
@@ -730,6 +731,82 @@ func (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": success, "failCount": fail}))
}
func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, error) {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
return nil, err
}
chainRows, err := h.listChainNodesForTunnel(tunnelID)
if err != nil {
return nil, err
}
state := &tunnelCreateState{
TunnelID: tunnelID,
Type: tunnel.Type,
InNodes: make([]tunnelRuntimeNode, 0),
ChainHops: make([][]tunnelRuntimeNode, 0),
OutNodes: make([]tunnelRuntimeNode, 0),
Nodes: make(map[int64]*nodeRecord),
NodeIDList: make([]int64, 0),
}
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
for _, r := range inNodes {
state.InNodes = append(state.InNodes, tunnelRuntimeNode{
NodeID: r.NodeID,
Protocol: r.Protocol,
Strategy: r.Strategy,
ChainType: 1,
})
state.NodeIDList = append(state.NodeIDList, r.NodeID)
}
for _, r := range outNodes {
state.OutNodes = append(state.OutNodes, tunnelRuntimeNode{
NodeID: r.NodeID,
Protocol: r.Protocol,
Strategy: r.Strategy,
ChainType: 3,
Port: r.Port,
})
state.NodeIDList = append(state.NodeIDList, r.NodeID)
}
for _, hop := range chainHops {
stateHop := make([]tunnelRuntimeNode, 0)
for _, r := range hop {
stateHop = append(stateHop, tunnelRuntimeNode{
NodeID: r.NodeID,
Protocol: r.Protocol,
Strategy: r.Strategy,
ChainType: 2,
Inx: int(r.Inx),
Port: r.Port,
})
state.NodeIDList = append(state.NodeIDList, r.NodeID)
}
state.ChainHops = append(state.ChainHops, stateHop)
}
seen := make(map[int64]struct{})
for _, id := range state.NodeIDList {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
node, err := h.getNodeRecord(id)
if err != nil {
return nil, err
}
state.Nodes[id] = node
}
return state, nil
}
func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
ids := idsFromBody(r, w)
if ids == nil {
@@ -738,6 +815,26 @@ func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
success := 0
fail := 0
for _, tunnelID := range ids {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
fail++
continue
}
if tunnel.Type == 2 {
h.cleanupTunnelRuntime(tunnelID)
state, err := h.reconstructTunnelState(tunnelID)
if err != nil {
fail++
continue
}
_, _, applyErr := h.applyTunnelRuntime(state)
if applyErr != nil {
fail++
continue
}
}
forwards, err := h.listForwardsByTunnel(tunnelID)
if err != nil {
fail++
@@ -840,6 +937,13 @@ func (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
// Fetch details to sync forwards
var userID, tunnelID int64
if err := h.repo.DB().QueryRow("SELECT user_id, tunnel_id FROM user_tunnel WHERE id = ?", id).Scan(&userID, &tunnelID); err == nil {
h.syncUserTunnelForwards(userID, tunnelID)
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -948,6 +1052,11 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
oldPorts, err := h.listForwardPorts(id)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
tunnelID := asInt64(req["tunnelId"], forward.TunnelID)
if tunnelID <= 0 {
@@ -1000,13 +1109,19 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
_ = h.replaceForwardPorts(id, tunnelID, port)
if err := h.replaceForwardPorts(id, tunnelID, port); err != nil {
h.rollbackForwardMutation(forward, oldPorts)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
updatedForward, err := h.getForwardRecord(id)
if err != nil {
h.rollbackForwardMutation(forward, oldPorts)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.syncForwardServices(updatedForward, "UpdateService", true); err != nil {
h.rollbackForwardMutation(forward, oldPorts)
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
@@ -1291,6 +1406,11 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
fail++
continue
}
oldPorts, listPortsErr := h.listForwardPorts(id)
if listPortsErr != nil {
fail++
continue
}
var port sql.NullInt64
_ = h.repo.DB().QueryRow(`SELECT MIN(port) FROM forward_port WHERE forward_id = ?`, id).Scan(&port)
_, err := h.repo.DB().Exec(`UPDATE forward SET tunnel_id = ?, updated_time = ? WHERE id = ?`, req.TargetTunnelID, time.Now().UnixMilli(), id)
@@ -1305,13 +1425,19 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
if p <= 0 {
p = h.pickTunnelPort(req.TargetTunnelID)
}
_ = h.replaceForwardPorts(id, req.TargetTunnelID, p)
if err := h.replaceForwardPorts(id, req.TargetTunnelID, p); err != nil {
h.rollbackForwardMutation(forward, oldPorts)
fail++
continue
}
updatedForward, fetchErr := h.getForwardRecord(id)
if fetchErr != nil {
h.rollbackForwardMutation(forward, oldPorts)
fail++
continue
}
if err := h.syncForwardServices(updatedForward, "UpdateService", true); err != nil {
h.rollbackForwardMutation(forward, oldPorts)
fail++
continue
}
@@ -2256,8 +2382,8 @@ func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{
return pickErr
}
}
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 3, ?, ?, NULL, 0, ?)`,
tunnelID, nodeID, port, defaultString(asString(n["protocol"]), "tls"))
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 3, ?, ?, ?, 0, ?)`,
tunnelID, nodeID, port, defaultString(asString(n["strategy"]), "round"), defaultString(asString(n["protocol"]), "tls"))
if err != nil {
return err
}
@@ -2377,44 +2503,187 @@ func (h *Handler) tunnelEntryNodeIDs(tunnelID int64) ([]int64, error) {
}
func (h *Handler) pickTunnelPort(tunnelID int64) int {
entry, _ := h.tunnelEntryNodeIDs(tunnelID)
if len(entry) == 0 {
entryNodes, err := h.tunnelEntryNodeIDs(tunnelID)
if err != nil || len(entryNodes) == 0 {
return 10000
}
var portRange string
_ = h.repo.DB().QueryRow(`SELECT port FROM node WHERE id = ?`, entry[0]).Scan(&portRange)
if portRange == "" {
return 10000
}
first := strings.Split(portRange, ",")[0]
first = strings.TrimSpace(first)
if strings.Contains(first, "-") {
parts := strings.SplitN(first, "-", 2)
p, _ := strconv.Atoi(strings.TrimSpace(parts[0]))
if p > 0 {
return p
var commonAvailable []int
firstNode := true
for _, nodeID := range entryNodes {
var portRange string
if err := h.repo.DB().QueryRow("SELECT port FROM node WHERE id = ?", nodeID).Scan(&portRange); err != nil {
continue
}
if portRange == "" {
portRange = "1000-65535"
}
nodePorts, err := parsePorts(portRange)
if err != nil {
continue
}
used, err := h.getUsedPorts(nodeID)
if err != nil {
continue
}
var available []int
for _, p := range nodePorts {
if !used[p] {
available = append(available, p)
}
}
if firstNode {
commonAvailable = available
firstNode = false
} else {
set := make(map[int]bool)
for _, p := range available {
set[p] = true
}
var newCommon []int
for _, p := range commonAvailable {
if set[p] {
newCommon = append(newCommon, p)
}
}
commonAvailable = newCommon
}
if len(commonAvailable) == 0 {
break
}
}
if p, err := strconv.Atoi(first); err == nil && p > 0 {
return p
if len(commonAvailable) > 0 {
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(commonAvailable))))
return commonAvailable[idx.Int64()]
}
return 10000
}
func (h *Handler) getUsedPorts(nodeID int64) (map[int]bool, error) {
used := make(map[int]bool)
rows, err := h.repo.DB().Query("SELECT port FROM forward_port WHERE node_id = ?", nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var p int
if err := rows.Scan(&p); err == nil {
used[p] = true
}
}
rows2, err := h.repo.DB().Query("SELECT port FROM chain_tunnel WHERE node_id = ? AND port > 0", nodeID)
if err != nil {
return nil, err
}
defer rows2.Close()
for rows2.Next() {
var p int
if err := rows2.Scan(&p); err == nil {
used[p] = true
}
}
return used, nil
}
func parsePorts(portRange string) ([]int, error) {
var ports []int
parts := strings.Split(portRange, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.Contains(part, "-") {
rangeParts := strings.Split(part, "-")
if len(rangeParts) != 2 {
return nil, errors.New("invalid port range format")
}
start, err1 := strconv.Atoi(strings.TrimSpace(rangeParts[0]))
end, err2 := strconv.Atoi(strings.TrimSpace(rangeParts[1]))
if err1 != nil || err2 != nil || start > end {
return nil, errors.New("invalid port range values")
}
for i := start; i <= end; i++ {
ports = append(ports, i)
}
} else {
p, err := strconv.Atoi(part)
if err != nil {
return nil, errors.New("invalid port value")
}
ports = append(ports, p)
}
}
return ports, nil
}
func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int) error {
tx, err := h.repo.DB().Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
_, _ = tx.Exec(`DELETE FROM forward_port WHERE forward_id = ?`, forwardID)
entryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
if _, err := tx.Exec(`DELETE FROM forward_port WHERE forward_id = ?`, forwardID); err != nil {
return err
}
entryNodes, err := h.tunnelEntryNodeIDs(tunnelID)
if err != nil {
return err
}
for _, nodeID := range entryNodes {
_, _ = tx.Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port)
if _, err := tx.Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port); err != nil {
return err
}
}
return tx.Commit()
}
func (h *Handler) replaceForwardPortsWithRecords(forwardID int64, ports []forwardPortRecord) error {
tx, err := h.repo.DB().Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`DELETE FROM forward_port WHERE forward_id = ?`, forwardID); err != nil {
return err
}
for _, fp := range ports {
if _, err := tx.Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, fp.NodeID, fp.Port); err != nil {
return err
}
}
return tx.Commit()
}
func (h *Handler) rollbackForwardMutation(oldForward *forwardRecord, oldPorts []forwardPortRecord) {
if h == nil || oldForward == nil || h.repo == nil || h.repo.DB() == nil {
return
}
_, _ = h.repo.DB().Exec(`
UPDATE forward
SET user_id = ?, user_name = ?, name = ?, tunnel_id = ?, remote_addr = ?, strategy = ?, status = ?, updated_time = ?
WHERE id = ?
`, oldForward.UserID, oldForward.UserName, oldForward.Name, oldForward.TunnelID, oldForward.RemoteAddr, oldForward.Strategy, oldForward.Status, time.Now().UnixMilli(), oldForward.ID)
if err := h.replaceForwardPortsWithRecords(oldForward.ID, oldPorts); err != nil {
return
}
_ = h.syncForwardServices(oldForward, "UpdateService", true)
}
func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
userID := asInt64(req["userId"], 0)
tunnelID := asInt64(req["tunnelId"], 0)
@@ -2423,41 +2692,120 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
}
db := h.repo.DB()
var existingID int64
err := db.QueryRow(`SELECT id FROM user_tunnel WHERE user_id = ? AND tunnel_id = ? LIMIT 1`, userID, tunnelID).Scan(&existingID)
flow := asInt64(req["flow"], -1)
num := asInt(req["num"], -1)
expTime := asInt64(req["expTime"], -1)
flowReset := asInt64(req["flowResetTime"], -1)
status := asInt(req["status"], 1)
var currentFlow, currentNum, currentExpTime, currentFlowReset int64
var currentSpeedID sql.NullInt64
var currentStatus int
err := db.QueryRow(`
SELECT id, flow, num, exp_time, flow_reset_time, speed_id, status
FROM user_tunnel
WHERE user_id = ? AND tunnel_id = ?
LIMIT 1
`, userID, tunnelID).Scan(&existingID, &currentFlow, &currentNum, &currentExpTime, &currentFlowReset, &currentSpeedID, &currentStatus)
speedID := asAnyToInt64Ptr(req["speedId"])
reqFlow := asInt64(req["flow"], -1)
reqNum := asInt(req["num"], -1)
reqExpTime := asInt64(req["expTime"], -1)
reqFlowReset := asInt64(req["flowResetTime"], -1)
reqStatus := asInt(req["status"], -1)
if err == sql.ErrNoRows {
if flow < 0 || num < 0 || expTime < 0 || flowReset < 0 {
_ = db.QueryRow(`SELECT flow, num, exp_time, flow_reset_time FROM user WHERE id = ?`, userID).Scan(&flow, &num, &expTime, &flowReset)
if reqFlow < 0 || reqNum < 0 || reqExpTime < 0 || reqFlowReset < 0 {
var uFlow, uNum, uExp, uReset int64
if uErr := db.QueryRow(`SELECT flow, num, exp_time, flow_reset_time FROM user WHERE id = ?`, userID).Scan(&uFlow, &uNum, &uExp, &uReset); uErr == nil {
if reqFlow < 0 {
reqFlow = uFlow
}
if reqNum < 0 {
reqNum = int(uNum)
}
if reqExpTime < 0 {
reqExpTime = uExp
}
if reqFlowReset < 0 {
reqFlowReset = uReset
}
}
}
if reqFlow < 0 {
reqFlow = 0
}
if reqNum < 0 {
reqNum = 0
}
if reqExpTime < 0 {
reqExpTime = time.Now().Add(365 * 24 * time.Hour).UnixMilli()
}
if reqFlowReset < 0 {
reqFlowReset = 1
}
if reqStatus < 0 {
reqStatus = 1
}
_, err = db.Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, ?, ?, ?, 0, 0, ?, ?, ?)`,
userID, tunnelID, nullableInt(speedID), num, flow, flowReset, expTime, status)
userID, tunnelID, nullableInt(speedID), reqNum, reqFlow, reqFlowReset, reqExpTime, reqStatus)
return err
}
if err != nil {
return err
}
if flow < 0 {
flow = 0
newFlow := currentFlow
if reqFlow >= 0 {
newFlow = reqFlow
}
if num < 0 {
num = 0
newNum := int(currentNum)
if reqNum >= 0 {
newNum = reqNum
}
if expTime < 0 {
expTime = time.Now().Add(365 * 24 * time.Hour).UnixMilli()
newExpTime := currentExpTime
if reqExpTime >= 0 {
newExpTime = reqExpTime
}
if flowReset < 0 {
flowReset = 1
newFlowReset := currentFlowReset
if reqFlowReset >= 0 {
newFlowReset = reqFlowReset
}
newStatus := currentStatus
if reqStatus >= 0 {
newStatus = reqStatus
}
newSpeedID := currentSpeedID
if speedID != nil {
newSpeedID = sql.NullInt64{Int64: *speedID, Valid: true}
} else if _, ok := req["speedId"]; ok {
newSpeedID = sql.NullInt64{Valid: false}
}
_, err = db.Exec(`UPDATE user_tunnel SET speed_id = ?, flow = ?, num = ?, exp_time = ?, flow_reset_time = ?, status = ? WHERE id = ?`,
nullableInt(speedID), flow, num, expTime, flowReset, status, existingID)
newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus, existingID)
if err == nil {
h.syncUserTunnelForwards(userID, tunnelID)
}
return err
}
func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) {
forwards, err := h.listForwardsByTunnel(tunnelID)
if err != nil {
return
}
for i := range forwards {
f := &forwards[i]
if f.UserID == userID {
_ = h.syncForwardServices(f, "UpdateService", true)
}
}
}
func asAnySlice(v interface{}) []interface{} {
if v == nil {
return nil
+1 -11
View File
@@ -5,7 +5,6 @@ import (
_ "embed"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"sort"
@@ -1167,19 +1166,10 @@ func bootstrapSchema(db *sql.DB) error {
return errors.New("nil db")
}
var exists int
err := db.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name='user'`).Scan(&exists)
if err != nil {
return fmt.Errorf("check schema: %w", err)
}
if exists > 0 {
return nil
}
log.Printf("sqlite schema not found, bootstrapping embedded schema")
if _, err := db.Exec(embeddedSchema); err != nil {
return fmt.Errorf("apply schema.sql: %w", err)
}
if _, err := db.Exec(embeddedSeedData); err != nil {
return fmt.Errorf("apply data.sql: %w", err)
}
@@ -173,6 +173,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_tunnel_unique ON tunnel_group
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_user_unique ON user_group_user(user_group_id, user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_unique ON group_permission(user_group_id, tunnel_group_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_grant_unique ON group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_tunnel_unique ON user_tunnel(user_id, tunnel_id);
CREATE TABLE IF NOT EXISTS vite_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -197,6 +197,339 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
})
}
func TestForwardSwitchTunnelRollbackOnSyncFailure(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'switch_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now); err != nil {
t.Fatalf("insert user: %v", err)
}
insertTunnel := func(name string, inx int) int64 {
res, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, 1.0, 1, "tls", 99999, now, now, 1, nil, inx)
if err != nil {
t.Fatalf("insert tunnel %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("get tunnel id %s: %v", name, err)
}
return id
}
insertNode := func(name, ip, portRange string, inx int) int64 {
res, err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx)
if err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("get node id %s: %v", name, err)
}
return id
}
tunnelA := insertTunnel("switch-tunnel-a", 0)
tunnelB := insertTunnel("switch-tunnel-b", 1)
nodeA := insertNode("switch-node-a", "10.10.0.1", "21000-21010", 0)
nodeB := insertNode("switch-node-b", "10.10.0.2", "22000-22010", 1)
if _, err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 21001, 'round', 1, 'tls')
`, tunnelA, nodeA); err != nil {
t.Fatalf("insert chain_tunnel tunnelA: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 22001, 'round', 1, 'tls')
`, tunnelB, nodeB); err != nil {
t.Fatalf("insert chain_tunnel tunnelB: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelA); err != nil {
t.Fatalf("insert user_tunnel A: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(11, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelB); err != nil {
t.Fatalf("insert user_tunnel B: %v", err)
}
forwardRes, err := repo.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(2, 'switch_user', 'switch-forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelA, now, now)
if err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID, err := forwardRes.LastInsertId()
if err != nil {
t.Fatalf("get forward id: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeA, 21001); err != nil {
t.Fatalf("insert forward_port: %v", err)
}
payload := `{"id":` + jsonNumber(forwardID) + `,"tunnelId":` + jsonNumber(tunnelB) + `}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewBufferString(payload))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected update failure when node is offline")
}
var tunnelAfter int64
if err := repo.DB().QueryRow(`SELECT tunnel_id FROM forward WHERE id = ?`, forwardID).Scan(&tunnelAfter); err != nil {
t.Fatalf("query forward tunnel_id: %v", err)
}
if tunnelAfter != tunnelA {
t.Fatalf("expected tunnel rollback to %d, got %d", tunnelA, tunnelAfter)
}
var nodeAfter int64
var portAfter int
if err := repo.DB().QueryRow(`SELECT node_id, port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID).Scan(&nodeAfter, &portAfter); err != nil {
t.Fatalf("query forward_port: %v", err)
}
if nodeAfter != nodeA || portAfter != 21001 {
t.Fatalf("expected forward_port rollback to node=%d port=21001, got node=%d port=%d", nodeA, nodeAfter, portAfter)
}
}
func TestForwardBatchChangeTunnelRollbackOnSyncFailure(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'batch_switch_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now); err != nil {
t.Fatalf("insert user: %v", err)
}
tunnelResA, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES('batch-switch-tunnel-a', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, now, now)
if err != nil {
t.Fatalf("insert tunnel A: %v", err)
}
tunnelA, _ := tunnelResA.LastInsertId()
tunnelResB, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES('batch-switch-tunnel-b', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 1)
`, now, now)
if err != nil {
t.Fatalf("insert tunnel B: %v", err)
}
tunnelB, _ := tunnelResB.LastInsertId()
nodeResA, err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES('batch-switch-node-a', 'batch-switch-node-a-secret', '10.11.0.1', '10.11.0.1', '', '23000-23010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
`, now, now)
if err != nil {
t.Fatalf("insert node A: %v", err)
}
nodeA, _ := nodeResA.LastInsertId()
nodeResB, err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES('batch-switch-node-b', 'batch-switch-node-b-secret', '10.11.0.2', '10.11.0.2', '', '24000-24010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 1)
`, now, now)
if err != nil {
t.Fatalf("insert node B: %v", err)
}
nodeB, _ := nodeResB.LastInsertId()
if _, err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, 23001, 'round', 1, 'tls')`, tunnelA, nodeA); err != nil {
t.Fatalf("insert chain_tunnel A: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, 24001, 'round', 1, 'tls')`, tunnelB, nodeB); err != nil {
t.Fatalf("insert chain_tunnel B: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(20, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)`, tunnelA); err != nil {
t.Fatalf("insert user_tunnel A: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(21, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)`, tunnelB); err != nil {
t.Fatalf("insert user_tunnel B: %v", err)
}
forwardRes, err := repo.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(2, 'batch_switch_user', 'batch-switch-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelA, now, now)
if err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID, _ := forwardRes.LastInsertId()
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeA, 23001); err != nil {
t.Fatalf("insert forward_port: %v", err)
}
payload := `{"forwardIds":[` + jsonNumber(forwardID) + `],"targetTunnelId":` + jsonNumber(tunnelB) + `}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/batch-change-tunnel", bytes.NewBufferString(payload))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected API success envelope, got code=%d msg=%q", out.Code, out.Msg)
}
result, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected map result, got %T", out.Data)
}
if int(result["failCount"].(float64)) != 1 {
t.Fatalf("expected failCount=1, got %v", result["failCount"])
}
var tunnelAfter int64
if err := repo.DB().QueryRow(`SELECT tunnel_id FROM forward WHERE id = ?`, forwardID).Scan(&tunnelAfter); err != nil {
t.Fatalf("query forward tunnel_id: %v", err)
}
if tunnelAfter != tunnelA {
t.Fatalf("expected tunnel rollback to %d, got %d", tunnelA, tunnelAfter)
}
var nodeAfter int64
var portAfter int
if err := repo.DB().QueryRow(`SELECT node_id, port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID).Scan(&nodeAfter, &portAfter); err != nil {
t.Fatalf("query forward_port: %v", err)
}
if nodeAfter != nodeA || portAfter != 23001 {
t.Fatalf("expected forward_port rollback to node=%d port=23001, got node=%d port=%d", nodeA, nodeAfter, portAfter)
}
}
func TestUserTunnelReassignmentKeepsStableID(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(100, 'stable_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now); err != nil {
t.Fatalf("insert user: %v", err)
}
tunnelRes, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES('stable-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, now, now)
if err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID, _ := tunnelRes.LastInsertId()
// 1. Assign permission (creates new user_tunnel)
// userTunnelBatchAssign expects structure: {userId: 123, tunnels: [{tunnelId: 456, ...}]}
assignPayload := `{"userId":100,"tunnels":[{"tunnelId":` + jsonNumber(tunnelID) + `}]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/batch-assign", bytes.NewBufferString(assignPayload))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d msg=%q", out.Code, out.Msg)
}
var initialID int64
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID).Scan(&initialID); err != nil {
t.Fatalf("query initial user_tunnel id: %v", err)
}
// 2. Re-assign permission (should UPDATE, not INSERT)
reassignPayload := `{"userId":100,"tunnels":[{"tunnelId":` + jsonNumber(tunnelID) + `}]}`
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/batch-assign", bytes.NewBufferString(reassignPayload))
req2.Header.Set("Authorization", adminToken)
req2.Header.Set("Content-Type", "application/json")
res2 := httptest.NewRecorder()
router.ServeHTTP(res2, req2)
var out2 response.R
if err := json.NewDecoder(res2.Body).Decode(&out2); err != nil {
t.Fatalf("decode response 2: %v", err)
}
if out2.Code != 0 {
t.Fatalf("expected code 0, got %d msg=%q", out2.Code, out2.Msg)
}
// 3. Verify stable ID and no duplicates
var count int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID).Scan(&count); err != nil {
t.Fatalf("query count: %v", err)
}
if count != 1 {
t.Fatalf("expected exactly 1 user_tunnel record, got %d", count)
}
var currentID int64
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID).Scan(&currentID); err != nil {
t.Fatalf("query current user_tunnel: %v", err)
}
if currentID != initialID {
t.Fatalf("user_tunnel ID changed from %d to %d (unstable ID!)", initialID, currentID)
}
}
func jsonNumber(v int64) string {
return strconv.FormatInt(v, 10)
}
+31 -8
View File
@@ -3,6 +3,7 @@ package service
import (
"fmt"
"runtime"
"strconv"
"strings"
"time"
@@ -30,6 +31,7 @@ import (
logger_parser "github.com/go-gost/x/config/parsing/logger"
selector_parser "github.com/go-gost/x/config/parsing/selector"
tls_util "github.com/go-gost/x/internal/util/tls"
xtraffic "github.com/go-gost/x/limiter/traffic"
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
"github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util"
@@ -181,6 +183,32 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
)
}
var trafficLimiter listener.Option
if cfg.Limiter != "" {
lim := registry.TrafficLimiterRegistry().Get(cfg.Limiter)
if lim == nil {
// Try to parse as simple number (bandwidth in bytes/sec)
if val, err := strconv.Atoi(cfg.Limiter); err == nil && val > 0 {
lim = xtraffic.NewTrafficLimiter(
xtraffic.LimitsOption(fmt.Sprintf("%s %dB %dB", xtraffic.ServiceLimitKey, val, val)),
)
}
if lim == nil {
lim = xtraffic.NewTrafficLimiter(
xtraffic.LimitsOption(fmt.Sprintf("%s %s %s", xtraffic.ServiceLimitKey, cfg.Limiter, cfg.Limiter)),
)
}
}
trafficLimiter = listener.TrafficLimiterOption(
cache_limiter.NewCachedTrafficLimiter(
lim,
cache_limiter.RefreshIntervalOption(limiterRefreshInterval),
cache_limiter.CleanupIntervalOption(limiterCleanupInterval),
cache_limiter.ScopeOption(limiterScope),
),
)
}
listenOpts := []listener.Option{
listener.AddrOption(cfg.Addr),
listener.RouterOption(xchain.NewRouter(routerOpts...)),
@@ -188,14 +216,6 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
listener.AuthOption(auth_parser.Info(cfg.Listener.Auth)),
listener.TLSConfigOption(tlsConfig),
listener.AdmissionOption(xadmission.AdmissionGroup(admissions...)),
listener.TrafficLimiterOption(
cache_limiter.NewCachedTrafficLimiter(
registry.TrafficLimiterRegistry().Get(cfg.Limiter),
cache_limiter.RefreshIntervalOption(limiterRefreshInterval),
cache_limiter.CleanupIntervalOption(limiterCleanupInterval),
cache_limiter.ScopeOption(limiterScope),
),
),
listener.ConnLimiterOption(registry.ConnLimiterRegistry().Get(cfg.CLimiter)),
listener.ServiceOption(cfg.Name),
listener.ProxyProtocolOption(ppv),
@@ -203,6 +223,9 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
listener.NetnsOption(netnsIn),
listener.LoggerOption(listenerLogger),
}
if trafficLimiter != nil {
listenOpts = append(listenOpts, trafficLimiter)
}
if netnsIn != "" {
runtime.LockOSThread()
+8 -3
View File
@@ -4,9 +4,7 @@ go 1.22.0
toolchain go1.23.4
require (
github.com/shirou/gopsutil/v3 v3.24.5
)
require github.com/shirou/gopsutil/v3 v3.24.5
require (
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137
@@ -76,6 +74,7 @@ require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-ole/go-ole v1.2.6 // 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.20.0 // indirect
@@ -91,6 +90,7 @@ require (
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/klauspost/reedsolomon v1.11.8 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -103,6 +103,7 @@ require (
github.com/pion/udp/v2 v2.0.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/prometheus/client_model v0.6.0 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
@@ -110,6 +111,7 @@ require (
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
@@ -118,8 +120,11 @@ require (
github.com/templexxx/cpu v0.1.0 // indirect
github.com/templexxx/xorsimd v0.4.2 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.8.0 // indirect
+22
View File
@@ -65,6 +65,8 @@ github.com/go-gost/tls-dissector v0.1.1 h1:2zUOTPzCQAUQ54Rpy0UEi3JPMQSYsIFSeFeKr
github.com/go-gost/tls-dissector v0.1.1/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -102,6 +104,7 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -133,6 +136,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -175,6 +180,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -204,7 +211,12 @@ github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1H
github.com/shadowsocks/go-shadowsocks2 v0.1.5/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM=
github.com/shadowsocks/shadowsocks-go v0.0.0-20200409064450-3e585ff90601 h1:XU9hik0exChEmY92ALW4l9WnDodxLVS9yOSNh2SizaQ=
github.com/shadowsocks/shadowsocks-go v0.0.0-20200409064450-3e585ff90601/go.mod h1:mttDPaeLm87u74HMrP+n2tugXvIKWcwff/cqSX0lehY=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
@@ -244,6 +256,10 @@ github.com/templexxx/xorsimd v0.4.2 h1:ocZZ+Nvu65LGHmCLZ7OoCtg8Fx8jnHKK37SjvngUo
github.com/templexxx/xorsimd v0.4.2/go.mod h1:HgwaPoDREdi6OnULpSfxhzaiiSUY4Fi3JPn1wpt28NI=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
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.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
@@ -266,6 +282,8 @@ github.com/xtaci/tcpraw v1.2.25/go.mod h1:dKyZ2V75s0cZ7cbgJYdxPvms7af0joIeOyx1Gg
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68=
github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
@@ -321,15 +339,19 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.4.0/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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
+6
View File
@@ -136,6 +136,9 @@ func (l *trafficLimiter) In(ctx context.Context, key string, opts ...limiter.Opt
return nil
case limiter.ScopeClient:
if lim, ok := l.inLimits.Get(key); ok && lim != nil {
return lim.(traffic.Limiter)
}
return nil
case limiter.ScopeConn:
@@ -215,6 +218,9 @@ func (l *trafficLimiter) Out(ctx context.Context, key string, opts ...limiter.Op
return nil
case limiter.ScopeClient:
if lim, ok := l.outLimits.Get(key); ok && lim != nil {
return lim.(traffic.Limiter)
}
return nil
case limiter.ScopeConn:
+9 -7
View File
@@ -43,8 +43,8 @@ func updateChain(req updateChainRequest) error {
name := strings.TrimSpace(req.Chain)
if !registry.ChainRegistry().IsRegistered(name) {
return errors.New("chain " + name + " not found")
if registry.ChainRegistry().IsRegistered(name) {
registry.ChainRegistry().Unregister(name)
}
req.Data.Name = name
@@ -54,19 +54,22 @@ func updateChain(req updateChainRequest) error {
return errors.New("create chain " + name + " failed: " + err.Error())
}
registry.ChainRegistry().Unregister(name)
if err := registry.ChainRegistry().Register(name, v); err != nil {
return errors.New("chain " + name + " already exists")
}
config.OnUpdate(func(c *config.Config) error {
found := false
for i := range c.Chains {
if c.Chains[i].Name == name {
c.Chains[i] = &req.Data
found = true
break
}
}
if !found {
c.Chains = append(c.Chains, &req.Data)
}
return nil
})
@@ -77,10 +80,9 @@ func deleteChain(req deleteChainRequest) error {
name := strings.TrimSpace(req.Chain)
if !registry.ChainRegistry().IsRegistered(name) {
return errors.New("chain " + name + " not found")
if registry.ChainRegistry().IsRegistered(name) {
registry.ChainRegistry().Unregister(name)
}
registry.ChainRegistry().Unregister(name)
config.OnUpdate(func(c *config.Config) error {
chains := c.Chains
+4 -5
View File
@@ -10,7 +10,7 @@ import (
// configMutex 保护配置文件的并发写入
var configMutex sync.Mutex
func saveConfig() {
func saveConfig() error {
configMutex.Lock()
defer configMutex.Unlock()
@@ -18,14 +18,13 @@ func saveConfig() {
f, err := os.Create(file)
if err != nil {
return
return err
}
defer f.Close()
if err := config.Global().Write(f, "json"); err != nil {
return
return err
}
return
return nil
}
+9 -7
View File
@@ -37,27 +37,30 @@ func updateLimiter(req updateLimiterRequest) error {
name := strings.TrimSpace(req.Limiter)
if !registry.TrafficLimiterRegistry().IsRegistered(name) {
return errors.New("limiter " + name + " not found")
if registry.TrafficLimiterRegistry().IsRegistered(name) {
registry.TrafficLimiterRegistry().Unregister(name)
}
req.Data.Name = name
v := parser.ParseTrafficLimiter(&req.Data)
registry.TrafficLimiterRegistry().Unregister(name)
if err := registry.TrafficLimiterRegistry().Register(name, v); err != nil {
return errors.New("limiter " + name + " already exists")
}
config.OnUpdate(func(c *config.Config) error {
found := false
for i := range c.Limiters {
if c.Limiters[i].Name == name {
c.Limiters[i] = &req.Data
found = true
break
}
}
if !found {
c.Limiters = append(c.Limiters, &req.Data)
}
return nil
})
@@ -68,10 +71,9 @@ func deleteLimiter(req deleteLimiterRequest) error {
name := strings.TrimSpace(req.Limiter)
if !registry.TrafficLimiterRegistry().IsRegistered(name) {
return errors.New("limiter " + name + " not found")
if registry.TrafficLimiterRegistry().IsRegistered(name) {
registry.TrafficLimiterRegistry().Unregister(name)
}
registry.TrafficLimiterRegistry().Unregister(name)
config.OnUpdate(func(c *config.Config) error {
limiteres := c.Limiters
+41 -32
View File
@@ -87,36 +87,32 @@ func updateServices(req updateServicesRequest) error {
return errors.New("services list cannot be empty")
}
// 第一阶段:验证所有服务存在
for _, serviceConfig := range req.Data {
name := strings.TrimSpace(serviceConfig.Name)
// 第一阶段:验证所有服务名称有效性
for i := range req.Data {
name := strings.TrimSpace(req.Data[i].Name)
if name == "" {
return errors.New("service name is required")
}
serviceConfig.Name = name
old := registry.ServiceRegistry().Get(name)
if old == nil {
return errors.New("service " + name + " not found")
}
req.Data[i].Name = name
}
// 第二阶段:按照原来的updateService逻辑,逐个更新服务
for _, serviceConfig := range req.Data {
name := strings.TrimSpace(serviceConfig.Name)
serviceConfig.Name = name
// 第二阶段:逐个更新服务(Upsert模式:存在则更新,不存在则创建)
for i := range req.Data {
serviceConfig := &req.Data[i]
name := serviceConfig.Name
// 1. 获取旧服务
old := registry.ServiceRegistry().Get(name)
// 2. 关闭旧服务
old.Close()
// 3. 从注册表移除旧服务
registry.ServiceRegistry().Unregister(name)
// 2. 关闭旧服务 (如果存在)
if old != nil {
old.Close()
// 3. 从注册表移除旧服务
registry.ServiceRegistry().Unregister(name)
}
// 4. 解析新服务配置
svc, err := parser.ParseService(&serviceConfig)
svc, err := parser.ParseService(serviceConfig)
if err != nil {
return errors.New("create service " + name + " failed: " + err.Error())
}
@@ -133,13 +129,20 @@ func updateServices(req updateServicesRequest) error {
// 第三阶段:更新配置
config.OnUpdate(func(c *config.Config) error {
for _, serviceConfig := range req.Data {
for i := range c.Services {
if c.Services[i].Name == serviceConfig.Name {
c.Services[i] = &serviceConfig
for i := range req.Data {
// 创建副本以确保指针安全
cfgCopy := req.Data[i]
found := false
for j := range c.Services {
if c.Services[j].Name == cfgCopy.Name {
c.Services[j] = &cfgCopy
found = true
break
}
}
if !found {
c.Services = append(c.Services, &cfgCopy)
}
}
return nil
})
@@ -158,22 +161,22 @@ func deleteServices(req deleteServicesRequest) error {
name string
service service.Service
}
var namesToRemove []string
for _, serviceName := range req.Services {
name := strings.TrimSpace(serviceName)
if name == "" {
return errors.New("service name is required")
}
namesToRemove = append(namesToRemove, name)
svc := registry.ServiceRegistry().Get(name)
if svc == nil {
return errors.New("service " + name + " not found")
if svc != nil {
servicesToDelete = append(servicesToDelete, struct {
name string
service service.Service
}{name, svc})
}
servicesToDelete = append(servicesToDelete, struct {
name string
service service.Service
}{name, svc})
}
// 第二阶段:删除所有服务
@@ -181,6 +184,12 @@ func deleteServices(req deleteServicesRequest) error {
registry.ServiceRegistry().Unregister(std.name)
std.service.Close()
}
// 确保所有请求删除的服务都从注册表中移除(即使之前未找到实例)
for _, name := range namesToRemove {
if registry.ServiceRegistry().IsRegistered(name) {
registry.ServiceRegistry().Unregister(name)
}
}
// 第三阶段:更新配置
config.OnUpdate(func(c *config.Config) error {
@@ -188,8 +197,8 @@ func deleteServices(req deleteServicesRequest) error {
c.Services = nil
for _, s := range services {
shouldDelete := false
for _, std := range servicesToDelete {
if s.Name == std.name {
for _, name := range namesToRemove {
if s.Name == name {
shouldDelete = true
break
}
+10 -1
View File
@@ -586,7 +586,16 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
// 只有状态变更命令才保存配置
if needSaveConfig {
saveConfig()
if saveErr := saveConfig(); saveErr != nil {
fmt.Printf("❌ 保存配置失败: %v\n", saveErr)
if err == nil {
err = fmt.Errorf("保存配置失败: %v", saveErr)
} else {
err = fmt.Errorf("%v; 保存配置失败: %v", err, saveErr)
}
} else {
fmt.Println("✅ 配置已保存到 gost.json")
}
}
// 发送响应
-89
View File
@@ -1,89 +0,0 @@
package middleware
import (
"errors"
"strconv"
"time"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/utils"
"github.com/golang-jwt/jwt/v5"
"github.com/flipped-aurora/gin-vue-admin/server/model/common/response"
"github.com/gin-gonic/gin"
)
func JWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
// 我们这里jwt鉴权取头部信息 x-token 登录时回返回token信息 这里前端需要把token存储到cookie或者本地localStorage中 不过需要跟后端协商过期时间 可以约定刷新令牌或者重新登录
token := utils.GetToken(c)
if token == "" {
response.NoAuth("未登录或非法访问,请登录", c)
c.Abort()
return
}
if isBlacklist(token) {
response.NoAuth("您的帐户异地登陆或令牌失效", c)
utils.ClearToken(c)
c.Abort()
return
}
j := utils.NewJWT()
// parseToken 解析token包含的信息
claims, err := j.ParseToken(token)
if err != nil {
if errors.Is(err, utils.TokenExpired) {
response.NoAuth("登录已过期,请重新登录", c)
utils.ClearToken(c)
c.Abort()
return
}
response.NoAuth(err.Error(), c)
utils.ClearToken(c)
c.Abort()
return
}
// 已登录用户被管理员禁用 需要使该用户的jwt失效 此处比较消耗性能 如果需要 请自行打开
// 用户被删除的逻辑 需要优化 此处比较消耗性能 如果需要 请自行打开
//if user, err := userService.FindUserByUuid(claims.UUID.String()); err != nil || user.Enable == 2 {
// _ = jwtService.JsonInBlacklist(system.JwtBlacklist{Jwt: token})
// response.FailWithDetailed(gin.H{"reload": true}, err.Error(), c)
// c.Abort()
//}
c.Set("claims", claims)
if claims.ExpiresAt.Unix()-time.Now().Unix() < claims.BufferTime {
dr, _ := utils.ParseDuration(global.GVA_CONFIG.JWT.ExpiresTime)
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(dr))
newToken, _ := j.CreateTokenByOldToken(token, *claims)
newClaims, _ := j.ParseToken(newToken)
c.Header("new-token", newToken)
c.Header("new-expires-at", strconv.FormatInt(newClaims.ExpiresAt.Unix(), 10))
utils.SetToken(c, newToken, int(dr.Seconds()/60))
if global.GVA_CONFIG.System.UseMultipoint {
// 记录新的活跃jwt
_ = utils.SetRedisJWT(newToken, newClaims.Username)
}
}
c.Next()
if newToken, exists := c.Get("new-token"); exists {
c.Header("new-token", newToken.(string))
}
if newExpiresAt, exists := c.Get("new-expires-at"); exists {
c.Header("new-expires-at", newExpiresAt.(string))
}
}
}
//@author: [piexlmax](https://github.com/piexlmax)
//@function: IsBlacklist
//@description: 判断JWT是否在黑名单内部
//@param: jwt string
//@return: bool
func isBlacklist(jwt string) bool {
_, ok := global.BlackCache.Get(jwt)
return ok
}
-62
View File
@@ -1,62 +0,0 @@
package response
import (
"net/http"
"github.com/gin-gonic/gin"
)
type Response struct {
Code int `json:"code"`
Data interface{} `json:"data"`
Msg string `json:"msg"`
}
const (
ERROR = 7
SUCCESS = 0
)
func Result(code int, data interface{}, msg string, c *gin.Context) {
c.JSON(http.StatusOK, Response{
code,
data,
msg,
})
}
func Ok(c *gin.Context) {
Result(SUCCESS, map[string]interface{}{}, "操作成功", c)
}
func OkWithMessage(message string, c *gin.Context) {
Result(SUCCESS, map[string]interface{}{}, message, c)
}
func OkWithData(data interface{}, c *gin.Context) {
Result(SUCCESS, data, "成功", c)
}
func OkWithDetailed(data interface{}, message string, c *gin.Context) {
Result(SUCCESS, data, message, c)
}
func Fail(c *gin.Context) {
Result(ERROR, map[string]interface{}{}, "操作失败", c)
}
func FailWithMessage(message string, c *gin.Context) {
Result(ERROR, map[string]interface{}{}, message, c)
}
func NoAuth(message string, c *gin.Context) {
c.JSON(http.StatusUnauthorized, Response{
7,
nil,
message,
})
}
func FailWithDetailed(data interface{}, message string, c *gin.Context) {
Result(ERROR, data, message, c)
}
-28
View File
@@ -1,28 +0,0 @@
package system
import (
"github.com/flipped-aurora/gin-vue-admin/server/middleware"
"github.com/gin-gonic/gin"
)
type UserRouter struct{}
func (s *UserRouter) InitUserRouter(Router *gin.RouterGroup) {
userRouter := Router.Group("user").Use(middleware.OperationRecord())
userRouterWithoutRecord := Router.Group("user")
{
userRouter.POST("admin_register", baseApi.Register) // 管理员注册账号
userRouter.POST("changePassword", baseApi.ChangePassword) // 用户修改密码
userRouter.POST("setUserAuthority", baseApi.SetUserAuthority) // 设置用户权限
userRouter.DELETE("deleteUser", baseApi.DeleteUser) // 删除用户
userRouter.PUT("setUserInfo", baseApi.SetUserInfo) // 设置用户信息
userRouter.PUT("setSelfInfo", baseApi.SetSelfInfo) // 设置自身信息
userRouter.POST("setUserAuthorities", baseApi.SetUserAuthorities) // 设置用户权限组
userRouter.POST("resetPassword", baseApi.ResetPassword) // 重置用户密码
userRouter.PUT("setSelfSetting", baseApi.SetSelfSetting) // 用户界面配置
}
{
userRouterWithoutRecord.POST("getUserList", baseApi.GetUserList) // 分页获取用户列表
userRouterWithoutRecord.GET("getUserInfo", baseApi.GetUserInfo) // 获取自身信息
}
}
-563
View File
@@ -1,563 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXContainerItemProxy section */
296FEB8A2E56AAF900DC77A3 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 296FEB742E56AAF700DC77A3 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 296FEB7B2E56AAF700DC77A3;
remoteInfo = flux;
};
296FEB942E56AAF900DC77A3 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 296FEB742E56AAF700DC77A3 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 296FEB7B2E56AAF700DC77A3;
remoteInfo = flux;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
296FEB7C2E56AAF700DC77A3 /* flux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = flux.app; sourceTree = BUILT_PRODUCTS_DIR; };
296FEB892E56AAF900DC77A3 /* fluxTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = fluxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
296FEB932E56AAF900DC77A3 /* fluxUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = fluxUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
296FEB7E2E56AAF700DC77A3 /* flux */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = flux;
sourceTree = "<group>";
};
296FEB8C2E56AAF900DC77A3 /* fluxTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = fluxTests;
sourceTree = "<group>";
};
296FEB962E56AAF900DC77A3 /* fluxUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = fluxUITests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
296FEB792E56AAF700DC77A3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
296FEB862E56AAF900DC77A3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
296FEB902E56AAF900DC77A3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
296FEB732E56AAF700DC77A3 = {
isa = PBXGroup;
children = (
296FEB7E2E56AAF700DC77A3 /* flux */,
296FEB8C2E56AAF900DC77A3 /* fluxTests */,
296FEB962E56AAF900DC77A3 /* fluxUITests */,
296FEB7D2E56AAF700DC77A3 /* Products */,
);
sourceTree = "<group>";
};
296FEB7D2E56AAF700DC77A3 /* Products */ = {
isa = PBXGroup;
children = (
296FEB7C2E56AAF700DC77A3 /* flux.app */,
296FEB892E56AAF900DC77A3 /* fluxTests.xctest */,
296FEB932E56AAF900DC77A3 /* fluxUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
296FEB7B2E56AAF700DC77A3 /* flux */ = {
isa = PBXNativeTarget;
buildConfigurationList = 296FEB9D2E56AAF900DC77A3 /* Build configuration list for PBXNativeTarget "flux" */;
buildPhases = (
296FEB782E56AAF700DC77A3 /* Sources */,
296FEB792E56AAF700DC77A3 /* Frameworks */,
296FEB7A2E56AAF700DC77A3 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
296FEB7E2E56AAF700DC77A3 /* flux */,
);
name = flux;
packageProductDependencies = (
);
productName = flux;
productReference = 296FEB7C2E56AAF700DC77A3 /* flux.app */;
productType = "com.apple.product-type.application";
};
296FEB882E56AAF900DC77A3 /* fluxTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 296FEBA02E56AAF900DC77A3 /* Build configuration list for PBXNativeTarget "fluxTests" */;
buildPhases = (
296FEB852E56AAF900DC77A3 /* Sources */,
296FEB862E56AAF900DC77A3 /* Frameworks */,
296FEB872E56AAF900DC77A3 /* Resources */,
);
buildRules = (
);
dependencies = (
296FEB8B2E56AAF900DC77A3 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
296FEB8C2E56AAF900DC77A3 /* fluxTests */,
);
name = fluxTests;
packageProductDependencies = (
);
productName = fluxTests;
productReference = 296FEB892E56AAF900DC77A3 /* fluxTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
296FEB922E56AAF900DC77A3 /* fluxUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 296FEBA32E56AAF900DC77A3 /* Build configuration list for PBXNativeTarget "fluxUITests" */;
buildPhases = (
296FEB8F2E56AAF900DC77A3 /* Sources */,
296FEB902E56AAF900DC77A3 /* Frameworks */,
296FEB912E56AAF900DC77A3 /* Resources */,
);
buildRules = (
);
dependencies = (
296FEB952E56AAF900DC77A3 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
296FEB962E56AAF900DC77A3 /* fluxUITests */,
);
name = fluxUITests;
packageProductDependencies = (
);
productName = fluxUITests;
productReference = 296FEB932E56AAF900DC77A3 /* fluxUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
296FEB742E56AAF700DC77A3 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1640;
LastUpgradeCheck = 1640;
TargetAttributes = {
296FEB7B2E56AAF700DC77A3 = {
CreatedOnToolsVersion = 16.4;
};
296FEB882E56AAF900DC77A3 = {
CreatedOnToolsVersion = 16.4;
TestTargetID = 296FEB7B2E56AAF700DC77A3;
};
296FEB922E56AAF900DC77A3 = {
CreatedOnToolsVersion = 16.4;
TestTargetID = 296FEB7B2E56AAF700DC77A3;
};
};
};
buildConfigurationList = 296FEB772E56AAF700DC77A3 /* Build configuration list for PBXProject "flux" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 296FEB732E56AAF700DC77A3;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 296FEB7D2E56AAF700DC77A3 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
296FEB7B2E56AAF700DC77A3 /* flux */,
296FEB882E56AAF900DC77A3 /* fluxTests */,
296FEB922E56AAF900DC77A3 /* fluxUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
296FEB7A2E56AAF700DC77A3 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
296FEB872E56AAF900DC77A3 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
296FEB912E56AAF900DC77A3 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
296FEB782E56AAF700DC77A3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
296FEB852E56AAF900DC77A3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
296FEB8F2E56AAF900DC77A3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
296FEB8B2E56AAF900DC77A3 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 296FEB7B2E56AAF700DC77A3 /* flux */;
targetProxy = 296FEB8A2E56AAF900DC77A3 /* PBXContainerItemProxy */;
};
296FEB952E56AAF900DC77A3 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 296FEB7B2E56AAF700DC77A3 /* flux */;
targetProxy = 296FEB942E56AAF900DC77A3 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
296FEB9B2E56AAF900DC77A3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
296FEB9C2E56AAF900DC77A3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
296FEB9E2E56AAF900DC77A3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = XRXD4YN2Y6;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = bqlpfy.flux;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
296FEB9F2E56AAF900DC77A3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = XRXD4YN2Y6;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = bqlpfy.flux;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
296FEBA12E56AAF900DC77A3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = bqlpfy.fluxTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flux.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flux";
};
name = Debug;
};
296FEBA22E56AAF900DC77A3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = bqlpfy.fluxTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flux.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flux";
};
name = Release;
};
296FEBA42E56AAF900DC77A3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = bqlpfy.fluxUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = flux;
};
name = Debug;
};
296FEBA52E56AAF900DC77A3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = bqlpfy.fluxUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = flux;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
296FEB772E56AAF700DC77A3 /* Build configuration list for PBXProject "flux" */ = {
isa = XCConfigurationList;
buildConfigurations = (
296FEB9B2E56AAF900DC77A3 /* Debug */,
296FEB9C2E56AAF900DC77A3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
296FEB9D2E56AAF900DC77A3 /* Build configuration list for PBXNativeTarget "flux" */ = {
isa = XCConfigurationList;
buildConfigurations = (
296FEB9E2E56AAF900DC77A3 /* Debug */,
296FEB9F2E56AAF900DC77A3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
296FEBA02E56AAF900DC77A3 /* Build configuration list for PBXNativeTarget "fluxTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
296FEBA12E56AAF900DC77A3 /* Debug */,
296FEBA22E56AAF900DC77A3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
296FEBA32E56AAF900DC77A3 /* Build configuration list for PBXNativeTarget "fluxUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
296FEBA42E56AAF900DC77A3 /* Debug */,
296FEBA52E56AAF900DC77A3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 296FEB742E56AAF700DC77A3 /* Project object */;
}
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -1,217 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>top-level-items</key>
<array>
<dict>
<key>children</key>
<array>
<dict>
<key>children</key>
<array>
<dict>
<key>destination</key>
<dict>
<key>rebasable-url</key>
<dict>
<key>base</key>
<string>workspace</string>
<key>payload</key>
<dict>
<key>relative-path</key>
<string>flux/dist/assets/bg-Ck8MrDZ5.jpg</string>
</dict>
</dict>
<key>type</key>
<string>DVTDocumentLocation</string>
</dict>
<key>type</key>
<string>bookmark</string>
</dict>
<dict>
<key>destination</key>
<dict>
<key>rebasable-url</key>
<dict>
<key>base</key>
<string>workspace</string>
<key>payload</key>
<dict>
<key>relative-path</key>
<string>flux/dist/assets/favicon-DI5FVxIz.ico</string>
</dict>
</dict>
<key>type</key>
<string>DVTDocumentLocation</string>
</dict>
<key>type</key>
<string>bookmark</string>
</dict>
<dict>
<key>destination</key>
<dict>
<key>rebasable-url</key>
<dict>
<key>base</key>
<string>workspace</string>
<key>payload</key>
<dict>
<key>relative-path</key>
<string>flux/dist/assets/index-Bf4rZ0gS.css</string>
</dict>
</dict>
<key>type</key>
<string>DVTDocumentLocation</string>
</dict>
<key>type</key>
<string>bookmark</string>
</dict>
<dict>
<key>destination</key>
<dict>
<key>rebasable-url</key>
<dict>
<key>base</key>
<string>workspace</string>
<key>payload</key>
<dict>
<key>relative-path</key>
<string>flux/dist/assets/index-ByTRlFFk.js</string>
</dict>
</dict>
<key>type</key>
<string>DVTDocumentLocation</string>
</dict>
<key>type</key>
<string>bookmark</string>
</dict>
<dict>
<key>destination</key>
<dict>
<key>rebasable-url</key>
<dict>
<key>base</key>
<string>workspace</string>
<key>payload</key>
<dict>
<key>relative-path</key>
<string>flux/dist/assets/index-Drx943H4.js</string>
</dict>
</dict>
<key>type</key>
<string>DVTDocumentLocation</string>
</dict>
<key>type</key>
<string>bookmark</string>
</dict>
<dict>
<key>destination</key>
<dict>
<key>rebasable-url</key>
<dict>
<key>base</key>
<string>workspace</string>
<key>payload</key>
<dict>
<key>relative-path</key>
<string>flux/dist/assets/index-legacy-BYgRAl5I.js</string>
</dict>
</dict>
<key>type</key>
<string>DVTDocumentLocation</string>
</dict>
<key>type</key>
<string>bookmark</string>
</dict>
<dict>
<key>destination</key>
<dict>
<key>rebasable-url</key>
<dict>
<key>base</key>
<string>workspace</string>
<key>payload</key>
<dict>
<key>relative-path</key>
<string>flux/dist/assets/index-legacy-DUPUAMxs.js</string>
</dict>
</dict>
<key>type</key>
<string>DVTDocumentLocation</string>
</dict>
<key>type</key>
<string>bookmark</string>
</dict>
<dict>
<key>destination</key>
<dict>
<key>rebasable-url</key>
<dict>
<key>base</key>
<string>workspace</string>
<key>payload</key>
<dict>
<key>relative-path</key>
<string>flux/dist/assets/polyfills-legacy-DIE4kOxF.js</string>
</dict>
</dict>
<key>type</key>
<string>DVTDocumentLocation</string>
</dict>
<key>type</key>
<string>bookmark</string>
</dict>
</array>
<key>title</key>
<string>assets</string>
<key>type</key>
<string>group</string>
</dict>
<dict>
<key>destination</key>
<dict>
<key>rebasable-url</key>
<dict>
<key>base</key>
<string>workspace</string>
<key>payload</key>
<dict>
<key>relative-path</key>
<string>flux/dist/index.html</string>
</dict>
</dict>
<key>type</key>
<string>DVTDocumentLocation</string>
</dict>
<key>type</key>
<string>bookmark</string>
</dict>
<dict>
<key>destination</key>
<dict>
<key>rebasable-url</key>
<dict>
<key>base</key>
<string>workspace</string>
<key>payload</key>
<dict>
<key>relative-path</key>
<string>flux/dist/vite.svg</string>
</dict>
</dict>
<key>type</key>
<string>DVTDocumentLocation</string>
</dict>
<key>type</key>
<string>bookmark</string>
</dict>
</array>
<key>title</key>
<string>dist</string>
<key>type</key>
<string>group</string>
</dict>
</array>
</dict>
</plist>
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>flux.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>
@@ -1,11 +0,0 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,35 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
-220
View File
@@ -1,220 +0,0 @@
import SwiftUI
import WebKit
struct WebView: UIViewRepresentable {
let fileName: String?
let urlString: String?
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
let userContentController = WKUserContentController()
// 注册 JS -> Native 消息通道
userContentController.add(context.coordinator, name: "getPanelAddresses")
userContentController.add(context.coordinator, name: "savePanelAddress")
userContentController.add(context.coordinator, name: "setCurrentPanelAddress")
userContentController.add(context.coordinator, name: "deletePanelAddress")
// 注入 CSS 隐藏滚动条
let css = "body::-webkit-scrollbar { display: none; }"
let cssJS = """
var style = document.createElement('style');
style.innerHTML = '\(css)';
document.head.appendChild(style);
"""
let cssScript = WKUserScript(source: cssJS, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
userContentController.addUserScript(cssScript)
config.userContentController = userContentController
let webView = WKWebView(frame: .zero, configuration: config)
context.coordinator.webView = webView
// 去掉滚动条和弹性
webView.scrollView.showsHorizontalScrollIndicator = false
webView.scrollView.showsVerticalScrollIndicator = false
webView.scrollView.bounces = false
// 加载本地 HTML 或远程 URL
if let fileName = fileName,
let htmlURL = Bundle.main.url(forResource: fileName, withExtension: "html") {
webView.loadFileURL(htmlURL, allowingReadAccessTo: htmlURL.deletingLastPathComponent())
} else if let urlString = urlString,
let url = URL(string: urlString) {
webView.load(URLRequest(url: url))
}
return webView
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
class Coordinator: NSObject, WKScriptMessageHandler {
weak var webView: WKWebView?
private let storageKey = "panel_addresses"
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
switch message.name {
case "getPanelAddresses":
handleGetPanelAddresses(message: message)
case "savePanelAddress":
handleSavePanelAddress(message: message)
case "setCurrentPanelAddress":
handleSetCurrentPanelAddress(message: message)
case "deletePanelAddress":
handleDeletePanelAddress(message: message)
default:
break
}
}
private func handleGetPanelAddresses(message: WKScriptMessage) {
let callbackName: String
if let cb = message.body as? String, !cb.isEmpty {
callbackName = cb
} else {
callbackName = "setPanelAddresses"
}
let addresses = loadAddressesJSONString()
let js = "window.\(callbackName)(\(addresses));"
evaluate(js: js)
}
private func handleSavePanelAddress(message: WKScriptMessage) {
var name: String?
var address: String?
if let dict = message.body as? [String: Any] {
name = dict["name"] as? String
address = dict["address"] as? String
} else if let arr = message.body as? [Any], arr.count >= 2 {
name = arr[0] as? String
address = arr[1] as? String
} else if let jsonString = message.body as? String,
let data = jsonString.data(using: .utf8),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
name = dict["name"] as? String
address = dict["address"] as? String
}
guard let panelName = name, let panelAddress = address, !panelName.isEmpty, !panelAddress.isEmpty else {
return
}
var array = loadAddressesArray()
let newItem: [String: Any] = [
"name": panelName,
"address": panelAddress,
"inx": false
]
array.append(newItem)
saveAddressesArray(array)
let js = "window.setPanelAddresses(\(toJSONString(array)));"
evaluate(js: js)
}
private func handleSetCurrentPanelAddress(message: WKScriptMessage) {
var name: String?
if let str = message.body as? String {
name = str
} else if let dict = message.body as? [String: Any] {
name = dict["name"] as? String
}
guard let targetName = name else { return }
var array = loadAddressesArray()
for i in 0..<array.count {
var obj = array[i]
if let n = obj["name"] as? String, n == targetName {
obj["inx"] = true
} else {
obj["inx"] = false
}
array[i] = obj
}
saveAddressesArray(array)
let js = "window.setPanelAddresses(\(toJSONString(array)));"
evaluate(js: js)
}
private func handleDeletePanelAddress(message: WKScriptMessage) {
var name: String?
if let str = message.body as? String {
name = str
} else if let dict = message.body as? [String: Any] {
name = dict["name"] as? String
}
guard let targetName = name else { return }
let oldArray = loadAddressesArray()
let newArray = oldArray.filter { obj in
guard let n = obj["name"] as? String else { return true }
return n != targetName
}
saveAddressesArray(newArray)
let js = "window.setPanelAddresses(\(toJSONString(newArray)));"
evaluate(js: js)
}
private func loadAddressesJSONString() -> String {
let defaults = UserDefaults.standard
if let s = defaults.string(forKey: storageKey), !s.isEmpty {
return s
}
return "[]"
}
private func loadAddressesArray() -> [[String: Any]] {
let s = loadAddressesJSONString()
guard let data = s.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
return []
}
return json
}
private func saveAddressesArray(_ array: [[String: Any]]) {
let jsonString = toJSONString(array)
let defaults = UserDefaults.standard
defaults.set(jsonString, forKey: storageKey)
}
private func toJSONString(_ array: [[String: Any]]) -> String {
if let data = try? JSONSerialization.data(withJSONObject: array, options: []),
let s = String(data: data, encoding: .utf8) {
return s
}
return "[]"
}
private func evaluate(js: String) {
DispatchQueue.main.async { [weak self] in
self?.webView?.evaluateJavaScript(js, completionHandler: nil)
}
}
}
}
struct ContentView: View {
var body: some View {
//WebView(fileName: nil, urlString: "http://192.168.100.9:3000")
WebView(fileName: "index", urlString: nil)
}
}
#Preview {
ContentView()
}
-17
View File
@@ -1,17 +0,0 @@
//
// fluxApp.swift
// flux
//
// Created by 何磊 on 21/08/2025.
//
import SwiftUI
@main
struct fluxApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
-17
View File
@@ -1,17 +0,0 @@
//
// fluxTests.swift
// fluxTests
//
// Created by 何磊 on 21/08/2025.
//
import Testing
@testable import flux
struct fluxTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
}
}
-41
View File
@@ -1,41 +0,0 @@
//
// fluxUITests.swift
// fluxUITests
//
// Created by 何磊 on 21/08/2025.
//
import XCTest
final class fluxUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
@MainActor
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}
@@ -1,33 +0,0 @@
//
// fluxUITestsLaunchTests.swift
// fluxUITests
//
// Created by 何磊 on 21/08/2025.
//
import XCTest
final class fluxUITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testLaunch() throws {
let app = XCUIApplication()
app.launch()
// Insert steps here to perform after app launch but before taking a screenshot,
// such as logging into a test account or navigating somewhere in the app
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
}
+40
View File
@@ -0,0 +1,40 @@
site_name: FLVX 文档
site_url: https://sagit-chu.github.io/flux-panel/
site_description: FLVX官方文档
site_author: Sagit-chu
theme:
name: material
language: zh
palette:
- scheme: default
primary: indigo
accent: indigo
- scheme: slate
primary: indigo
accent: indigo
toggle:
icon: material/brightness-4
name: Switch to dark mode
features:
- navigation.tabs
- navigation.sections
- navigation.top
- search.suggest
- search.highlight
- content.code.copy
docs_dir: doc
nav:
- 首页: index.md
- 安装部署: install.md
- 使用指南: usage.md
- 常见问题: faq.md
markdown_extensions:
- admonition
- pymdownx.details
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
-3
View File
@@ -1,3 +0,0 @@
<factorypath>
<factorypathentry kind="VARJAR" id="M2_REPO/org/projectlombok/lombok/1.18.30/lombok-1.18.30.jar" enabled="true" runInBatchMode="false"/>
</factorypath>
-39
View File
@@ -1,39 +0,0 @@
# SPRINGBOOT BACKEND KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
## OVERVIEW
Admin API for Flux Panel. Manages users, tunnels, nodes, forwards, quotas, and speed limits.
**Stack:** Java 21, Spring Boot 2.7.18, SQLite, MyBatis Plus (+ join), FastJSON2.
## STRUCTURE
```
springboot-backend/
├── src/main/java/com/admin/
│ ├── controller/ # /api/v1/* endpoints
│ ├── entity/ # DB models
│ ├── mapper/ # MyBatis Plus mappers
│ ├── service/ # Business logic
│ ├── config/ # WebMvc/JWT/CORS/WebSocket config
│ └── common/ # DTOs, auth, exception handling, utilities
└── src/main/resources/
├── application.yml # Config (DB_PATH/JWT_SECRET/LOG_DIR)
├── mapper/ # XML mappers
├── schema.sql # Schema
└── data.sql # Seed data
```
## CONVENTIONS
- **DB**: SQLite URL is `jdbc:sqlite:${DB_PATH:/app/data/gost.db}` (`springboot-backend/src/main/resources/application.yml`).
- **Auth**: JWT in `Authorization` header; enforced by `com.admin.common.interceptor.JwtInterceptor` for `/api/**` (with explicit excludes in `com.admin.config.WebMvcConfig`).
- **Roles**: `@RequireRole` means admin-only (`role_id == 0`) via `com.admin.common.aop.RoleAspect`.
- **Responses**: Controllers return `com.admin.common.lang.R` (`code == 0` success).
- **CORS**: Allow-all origins; `Authorization` is exposed (`com.admin.config.WebMvcConfig`).
## COMMANDS
```bash
cd springboot-backend
mvn clean package
mvn test
java -jar target/admin-0.0.1-SNAPSHOT.jar
```
-27
View File
@@ -1,27 +0,0 @@
# 构建阶段
FROM maven:3.9.6-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests
# 运行阶段
FROM eclipse-temurin:21-jre
WORKDIR /app
ENV JAVA_OPTS="-Xmx512m -Xms256m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
RUN sed -i 's/archive.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list \
&& sed -i 's/security.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends fontconfig fonts-dejavu sqlite3 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/target/*.jar app.jar
EXPOSE 6365
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
-180
View File
@@ -1,180 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.admin</groupId>
<artifactId>admin</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>admin</name>
<description>admin</description>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.7.18</spring-boot.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.43</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.github.yulichang</groupId>
<artifactId>mybatis-plus-join</artifactId>
<version>1.3.0</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.70</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.45.0.0</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.3.3</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.11</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.14</version>
</dependency>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.31</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- tianai-captcha Spring Boot Starter -->
<dependency>
<groupId>cloud.tianai.captcha</groupId>
<artifactId>tianai-captcha-springboot-starter</artifactId>
<version>1.5.2</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
<encoding>UTF-8</encoding>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<configuration>
<mainClass>com.admin.AdminApplication</mainClass>
</configuration>
<executions>
<execution>
<id>repackage</id>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -1,32 +0,0 @@
# SPRINGBOOT BACKEND (com.admin) KNOWLEDGE BASE
## OVERVIEW
Primary Java code for the admin API. Controllers expose `/api/v1/*` endpoints and return `R` response envelopes.
## STRUCTURE
```
springboot-backend/src/main/java/com/admin/
├── controller/ # REST controllers (e.g., /api/v1/user)
├── service/ # Business logic interfaces + impl/
├── mapper/ # MyBatis Plus mappers
├── entity/ # DB entities
├── config/ # WebMvc/JWT/CORS/WebSocket config
└── common/ # DTOs, auth, exception handling, utilities
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| User/login endpoints | `springboot-backend/src/main/java/com/admin/controller/UserController.java` | `/api/v1/user/*` |
| Auth enforcement | `springboot-backend/src/main/java/com/admin/config/WebMvcConfig.java` | Intercepts `/api/**`, excludes login/config/captcha |
| JWT validation | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Requires `Authorization` header |
| Admin-only ops | `springboot-backend/src/main/java/com/admin/common/annotation/RequireRole.java` | Enforced by `RoleAspect` |
| Response envelope | `springboot-backend/src/main/java/com/admin/common/lang/R.java` | `code == 0` success |
| Global error handling | `springboot-backend/src/main/java/com/admin/common/exception/GlobalExceptionHandler.java` | Maps exceptions -> `R.err(...)` |
## CONVENTIONS
- Controllers are mostly `@PostMapping` (even for list/get/delete) and use `/api/v1/*` prefixes.
- JWT is custom (no 3p lib) and includes `role_id` in payload (`springboot-backend/src/main/java/com/admin/common/utils/JwtUtil.java`).
## ANTI-PATTERNS
- Do not change auth header format lightly: frontend expects `Authorization: <token>` (no `Bearer`).
@@ -1,24 +0,0 @@
package com.admin;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableAsync
@EnableScheduling
@MapperScan("com.admin.mapper")
public class AdminApplication {
public static void main(String[] args) {
SpringApplication.run(AdminApplication.class, args);
}
}
@@ -1,15 +0,0 @@
package com.admin.common.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 权限控制注解
* 用于标记需要管理员权限的方法(role_id = 0)
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
}
@@ -1,9 +0,0 @@
package com.admin.common.aop;
import java.lang.annotation.*;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LogAnnotation {}

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