mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-14 16:54:24 +08:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fea1bf52f3 | |||
| a72d84fa76 | |||
| 146821ebba | |||
| 0191f29cf1 | |||
| 149a841a49 | |||
| 229ae9e454 | |||
| ae8a3db3df | |||
| 51660c413e | |||
| 2d2ca389e3 | |||
| 3799729706 | |||
| 8628c35802 | |||
| acea5ea76c | |||
| 8652380da1 | |||
| dd206ced14 | |||
| f720b92f53 | |||
| f879a58bb4 | |||
| b11283d488 | |||
| 70f8dfeac1 | |||
| 37005a1954 | |||
| b55e056316 | |||
| b3b7f5e56d | |||
| d6ff6ea500 | |||
| 9ed875b7ef | |||
| 6387ce1816 | |||
| 219067a27c | |||
| 33678477aa |
@@ -7,18 +7,17 @@ services:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
environment:
|
||||
DB_TYPE: ${DB_TYPE:-sqlite}
|
||||
DB_PATH: /app/data/gost.db
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LOG_DIR: /app/logs
|
||||
SERVER_ADDR: :6365
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
volumes:
|
||||
- backend_logs:/app/logs
|
||||
- sqlite_data:/app/data
|
||||
networks:
|
||||
- gost-network
|
||||
@@ -63,6 +62,7 @@ services:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
depends_on:
|
||||
@@ -79,9 +79,6 @@ volumes:
|
||||
postgres_data:
|
||||
name: postgres_data
|
||||
driver: local
|
||||
backend_logs:
|
||||
name: backend_logs
|
||||
driver: local
|
||||
|
||||
|
||||
networks:
|
||||
@@ -90,4 +87,4 @@ networks:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
- subnet: 172.80.0.0/16
|
||||
|
||||
@@ -7,18 +7,17 @@ services:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
environment:
|
||||
DB_TYPE: ${DB_TYPE:-sqlite}
|
||||
DB_PATH: /app/data/gost.db
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LOG_DIR: /app/logs
|
||||
SERVER_ADDR: :6365
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
volumes:
|
||||
- backend_logs:/app/logs
|
||||
- sqlite_data:/app/data
|
||||
networks:
|
||||
- gost-network
|
||||
@@ -63,6 +62,7 @@ services:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
depends_on:
|
||||
@@ -79,9 +79,6 @@ volumes:
|
||||
postgres_data:
|
||||
name: postgres_data
|
||||
driver: local
|
||||
backend_logs:
|
||||
name: backend_logs
|
||||
driver: local
|
||||
|
||||
|
||||
networks:
|
||||
@@ -91,5 +88,5 @@ networks:
|
||||
enable_ipv6: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
- subnet: 172.80.0.0/16
|
||||
- subnet: fd00:dead:beef::/48
|
||||
|
||||
@@ -77,6 +77,18 @@ type RuntimeDiagnoseRequest struct {
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
|
||||
type RuntimeNodeCommandRequest struct {
|
||||
CommandType string `json:"commandType"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type RuntimeNodeCommandResponse struct {
|
||||
Type string `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func NewFederationClient() *FederationClient {
|
||||
return &FederationClient{
|
||||
client: &http.Client{
|
||||
@@ -333,3 +345,42 @@ func (c *FederationClient) Diagnose(url, token, localDomain string, reqData Runt
|
||||
|
||||
return res.Data, nil
|
||||
}
|
||||
|
||||
func (c *FederationClient) Command(url, token, localDomain string, reqData RuntimeNodeCommandRequest) (*RuntimeNodeCommandResponse, error) {
|
||||
url = strings.TrimSuffix(url, "/")
|
||||
bodyBytes, _ := json.Marshal(reqData)
|
||||
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/command", strings.NewReader(string(bodyBytes)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if localDomain != "" {
|
||||
req.Header.Set("X-Panel-Domain", localDomain)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var res struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data RuntimeNodeCommandResponse `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != 0 {
|
||||
return nil, fmt.Errorf("remote api error: %s", res.Msg)
|
||||
}
|
||||
|
||||
return &res.Data, nil
|
||||
}
|
||||
|
||||
@@ -457,7 +457,17 @@ func (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVa
|
||||
}
|
||||
|
||||
func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interface{}, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {
|
||||
result, err := h.wsServer.SendCommand(nodeID, commandType, data, 12*time.Second)
|
||||
var (
|
||||
result ws.CommandResult
|
||||
err error
|
||||
)
|
||||
|
||||
node, nodeErr := h.getNodeRecord(nodeID)
|
||||
if nodeErr == nil && node != nil && node.IsRemote == 1 {
|
||||
result, err = h.sendRemoteNodeCommand(node, commandType, data)
|
||||
} else {
|
||||
result, err = h.wsServer.SendCommand(nodeID, commandType, data, 12*time.Second)
|
||||
}
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
@@ -475,6 +485,44 @@ func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interfa
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, data interface{}) (ws.CommandResult, error) {
|
||||
if node == nil {
|
||||
return ws.CommandResult{}, errors.New("节点不存在")
|
||||
}
|
||||
remoteURL := strings.TrimSpace(node.RemoteURL)
|
||||
remoteToken := strings.TrimSpace(node.RemoteToken)
|
||||
if remoteURL == "" || remoteToken == "" {
|
||||
return ws.CommandResult{}, errors.New("远程节点缺少共享配置")
|
||||
}
|
||||
|
||||
fc := client.NewFederationClient()
|
||||
res, err := fc.Command(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeNodeCommandRequest{
|
||||
CommandType: commandType,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
return ws.CommandResult{}, err
|
||||
}
|
||||
if res == nil {
|
||||
return ws.CommandResult{}, errors.New("远程节点未返回命令结果")
|
||||
}
|
||||
|
||||
result := ws.CommandResult{
|
||||
Type: res.Type,
|
||||
Success: res.Success,
|
||||
Message: res.Message,
|
||||
Data: res.Data,
|
||||
}
|
||||
if !result.Success {
|
||||
msg := strings.TrimSpace(result.Message)
|
||||
if msg == "" {
|
||||
msg = "命令执行失败"
|
||||
}
|
||||
return result, errors.New(msg)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]interface{}, error) {
|
||||
if forward == nil {
|
||||
return nil, errForwardNotFound
|
||||
@@ -889,11 +937,11 @@ func firstPortFromRange(portRange string) int {
|
||||
|
||||
func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, error) {
|
||||
rows, err := h.repo.DB().Query(`
|
||||
SELECT ct.chain_type, COALESCE(ct.inx, 0), ct.node_id, COALESCE(ct.port, 0), n.name, ct.protocol, ct.strategy
|
||||
SELECT CAST(ct.chain_type AS INTEGER), COALESCE(ct.inx, 0), ct.node_id, COALESCE(ct.port, 0), n.name, ct.protocol, ct.strategy
|
||||
FROM chain_tunnel ct
|
||||
LEFT JOIN node n ON n.id = ct.node_id
|
||||
WHERE ct.tunnel_id = ?
|
||||
ORDER BY ct.chain_type ASC, COALESCE(ct.inx, 0) ASC, ct.id ASC
|
||||
ORDER BY CAST(ct.chain_type AS INTEGER) ASC, COALESCE(ct.inx, 0) ASC, ct.id ASC
|
||||
`, tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -91,6 +91,11 @@ type federationRuntimeDiagnoseRequest struct {
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
|
||||
type federationRuntimeCommandRequest struct {
|
||||
CommandType string `json:"commandType"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type peerShareUsedPort struct {
|
||||
RuntimeID int64 `json:"runtimeId"`
|
||||
Port int `json:"port"`
|
||||
@@ -799,7 +804,7 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`INSERT INTO chain_tunnel (tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES (?, 1, ?, ?, 'fifo', 0, ?)`,
|
||||
_, err = tx.Exec(`INSERT INTO chain_tunnel (tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES (?, '1', ?, ?, 'fifo', 0, ?)`,
|
||||
tunnelID,
|
||||
share.NodeID,
|
||||
req.RemotePort,
|
||||
@@ -1000,14 +1005,19 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid target"))
|
||||
return
|
||||
}
|
||||
targetProtocol := defaultString(target.Protocol, protocol)
|
||||
connector := map[string]interface{}{
|
||||
"type": "relay",
|
||||
}
|
||||
if isTLSTunnelProtocol(targetProtocol) {
|
||||
connector["metadata"] = map[string]interface{}{"nodelay": true}
|
||||
}
|
||||
nodeItems = append(nodeItems, map[string]interface{}{
|
||||
"name": fmt.Sprintf("node_%d", i+1),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
|
||||
"connector": map[string]interface{}{
|
||||
"type": "relay",
|
||||
},
|
||||
"name": fmt.Sprintf("node_%d", i+1),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
|
||||
"connector": connector,
|
||||
"dialer": map[string]interface{}{
|
||||
"type": defaultString(target.Protocol, protocol),
|
||||
"type": targetProtocol,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1046,6 +1056,9 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
"type": protocol,
|
||||
},
|
||||
}
|
||||
if isTLSTunnelProtocol(protocol) {
|
||||
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{"nodelay": true}
|
||||
}
|
||||
if req.Role == "middle" {
|
||||
service["handler"].(map[string]interface{})["chain"] = chainName
|
||||
}
|
||||
@@ -1191,6 +1204,51 @@ func (h *Handler) federationRuntimeDiagnose(w http.ResponseWriter, r *http.Reque
|
||||
response.WriteJSON(w, response.OK(res.Data))
|
||||
}
|
||||
|
||||
func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||
return
|
||||
}
|
||||
|
||||
token := extractBearerToken(r)
|
||||
share, err := h.repo.GetPeerShareByToken(token)
|
||||
if err != nil || share == nil {
|
||||
response.WriteJSON(w, response.Err(401, "Unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
var req federationRuntimeCommandRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
|
||||
return
|
||||
}
|
||||
cmd := strings.TrimSpace(req.CommandType)
|
||||
if cmd == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("commandType is required"))
|
||||
return
|
||||
}
|
||||
if !isFederationRuntimeCommandAllowed(cmd) {
|
||||
response.WriteJSON(w, response.ErrDefault("command not allowed"))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.sendNodeCommand(share.NodeID, cmd, req.Data, false, false)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(res))
|
||||
}
|
||||
|
||||
func isFederationRuntimeCommandAllowed(commandType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(commandType)) {
|
||||
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "deletelimiters", "tcpping", "reload":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) pickPeerSharePort(share *sqlite.PeerShare, requestedPort int) (int, error) {
|
||||
if share == nil {
|
||||
return 0, fmt.Errorf("share not found")
|
||||
|
||||
@@ -152,6 +152,71 @@ func TestPrepareTunnelCreateStateRemoteAutoPortDefersToFederation(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
insertNode := func(name string, status int, portRange string, isRemote int) int64 {
|
||||
res, execErr := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":2}`)
|
||||
if execErr != nil {
|
||||
t.Fatalf("insert node %s: %v", name, execErr)
|
||||
}
|
||||
id, idErr := res.LastInsertId()
|
||||
if idErr != nil {
|
||||
t.Fatalf("node id %s: %v", name, idErr)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
entryID := insertNode("entry-local", 1, "32000-32010", 0)
|
||||
remoteMiddleID := insertNode("middle-remote", 0, "33000-33010", 1)
|
||||
outID := insertNode("out-local", 1, "34000-34010", 0)
|
||||
|
||||
tx, err := repo.DB().Begin()
|
||||
if err != nil {
|
||||
t.Fatalf("begin tx: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
req := map[string]interface{}{
|
||||
"name": "remote-middle-offline-status",
|
||||
"inNodeId": []interface{}{
|
||||
map[string]interface{}{"nodeId": float64(entryID), "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
"chainNodes": []interface{}{
|
||||
[]interface{}{
|
||||
map[string]interface{}{"nodeId": float64(remoteMiddleID), "protocol": "tls", "strategy": "round", "port": float64(0)},
|
||||
},
|
||||
},
|
||||
"outNodeId": []interface{}{
|
||||
map[string]interface{}{"nodeId": float64(outID), "protocol": "tls", "strategy": "round", "port": float64(0)},
|
||||
},
|
||||
}
|
||||
|
||||
state, err := h.prepareTunnelCreateState(tx, req, 2, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare state should allow offline remote middle node: %v", err)
|
||||
}
|
||||
if len(state.ChainHops) != 1 || len(state.ChainHops[0]) != 1 {
|
||||
t.Fatalf("expected one middle hop node, got %+v", state.ChainHops)
|
||||
}
|
||||
if state.ChainHops[0][0].NodeID != remoteMiddleID {
|
||||
t.Fatalf("expected remote middle node id %d, got %d", remoteMiddleID, state.ChainHops[0][0].NodeID)
|
||||
}
|
||||
if state.Nodes[remoteMiddleID] == nil || state.Nodes[remoteMiddleID].IsRemote != 1 {
|
||||
t.Fatalf("expected remote middle node metadata in state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
|
||||
@@ -169,8 +169,12 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/federation/runtime/apply-role", h.authPeer(h.federationRuntimeApplyRole))
|
||||
mux.HandleFunc("/api/v1/federation/runtime/release-role", h.authPeer(h.federationRuntimeReleaseRole))
|
||||
mux.HandleFunc("/api/v1/federation/runtime/diagnose", h.authPeer(h.federationRuntimeDiagnose))
|
||||
mux.HandleFunc("/api/v1/federation/runtime/command", h.authPeer(h.federationRuntimeCommand))
|
||||
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
|
||||
|
||||
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
|
||||
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
|
||||
|
||||
mux.HandleFunc("/flow/test", h.flowTest)
|
||||
mux.HandleFunc("/flow/config", h.flowConfig)
|
||||
mux.HandleFunc("/flow/upload", h.flowUpload)
|
||||
@@ -306,11 +310,35 @@ func (h *Handler) userList(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Current int `json:"current"`
|
||||
Size int `json:"size"`
|
||||
Keyword string `json:"keyword"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
users, err := h.repo.ListUsers()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
keyword := strings.ToLower(strings.TrimSpace(req.Keyword))
|
||||
if keyword != "" {
|
||||
filtered := make([]map[string]interface{}, 0, len(users))
|
||||
for _, item := range users {
|
||||
username := strings.ToLower(strings.TrimSpace(fmt.Sprint(item["user"])))
|
||||
displayName := strings.ToLower(strings.TrimSpace(fmt.Sprint(item["name"])))
|
||||
if strings.Contains(username, keyword) || strings.Contains(displayName, keyword) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
users = filtered
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(users))
|
||||
}
|
||||
|
||||
@@ -1116,3 +1144,84 @@ func (h *Handler) verifyCloudflareTurnstile(token, secretKey string) bool {
|
||||
}
|
||||
return body.Success
|
||||
}
|
||||
|
||||
type backupExportRequest struct {
|
||||
Types []string `json:"types"`
|
||||
}
|
||||
|
||||
func (h *Handler) backupExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req backupExportRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.Err(500, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
var backup interface{}
|
||||
var err error
|
||||
|
||||
if len(req.Types) == 0 {
|
||||
backup, err = h.repo.ExportAll()
|
||||
} else {
|
||||
backup, err = h.repo.ExportPartial(req.Types)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=backup.json")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(backup); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type backupImportRequest struct {
|
||||
Types []string `json:"types"`
|
||||
}
|
||||
|
||||
func (h *Handler) backupImport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req backupImportRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.Err(500, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Types) == 0 {
|
||||
response.WriteJSON(w, response.Err(500, "请选择要导入的数据类型"))
|
||||
return
|
||||
}
|
||||
|
||||
autoBackup, err := h.repo.ExportAll()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("导入前自动备份失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
var backup sqlite.BackupData
|
||||
if err := decodeJSON(r.Body, &backup); err != nil {
|
||||
response.WriteJSON(w, response.Err(500, "备份数据格式错误"))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.repo.Import(&backup, req.Types)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("导入失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
result.AutoBackup = autoBackup
|
||||
response.WriteJSON(w, response.OK(result))
|
||||
}
|
||||
|
||||
@@ -688,6 +688,9 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
runtimeState.TunnelID = id
|
||||
|
||||
inIp := buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes)
|
||||
|
||||
var federationBindings []sqlite.FederationTunnelBinding
|
||||
var federationReleaseRefs []federationRuntimeReleaseRef
|
||||
if typeVal == 2 {
|
||||
@@ -700,7 +703,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
applyTunnelPortsToRequest(req, runtimeState)
|
||||
|
||||
_, err = tx.Exec(`UPDATE tunnel SET name=?, type=?, flow=?, traffic_ratio=?, status=?, in_ip=?, updated_time=? WHERE id=?`,
|
||||
asString(req["name"]), typeVal, asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1), nullableText(asString(req["inIp"])), now, id)
|
||||
asString(req["name"]), typeVal, asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1), nullableText(inIp), now, id)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -1710,10 +1713,19 @@ func (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
previousUserIDs, err := queryInt64ListTx(tx, `SELECT user_id FROM user_group_user WHERE user_group_id = ?`, req.GroupID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
_, _ = tx.Exec(`DELETE FROM user_group_user WHERE user_group_id = ?`, req.GroupID)
|
||||
for _, uid := range req.UserIDs {
|
||||
_, _ = tx.Exec(`INSERT INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`, req.GroupID, uid, time.Now().UnixMilli())
|
||||
}
|
||||
if err := revokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -1745,10 +1757,35 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
|
||||
if id <= 0 {
|
||||
return
|
||||
}
|
||||
tx, err := h.repo.DB().Begin()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var ug, tg int64
|
||||
_ = h.repo.DB().QueryRow(`SELECT user_group_id, tunnel_group_id FROM group_permission WHERE id = ?`, id).Scan(&ug, &tg)
|
||||
_, _ = h.repo.DB().Exec(`DELETE FROM group_permission WHERE id = ?`, id)
|
||||
_, _ = h.repo.DB().Exec(`DELETE FROM group_permission_grant WHERE user_group_id = ? AND tunnel_group_id = ?`, ug, tg)
|
||||
err = tx.QueryRow(`SELECT user_group_id, tunnel_group_id FROM group_permission WHERE id = ?`, id).Scan(&ug, &tg)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM group_permission WHERE id = ?`, id); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
if err := revokeGroupPermissionPairTx(tx, ug, tg); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -1905,6 +1942,144 @@ func queryInt64List(db *store.DB, q string, args ...interface{}) ([]int64, error
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func queryInt64ListTx(tx *store.Tx, q string, args ...interface{}) ([]int64, error) {
|
||||
rows, err := tx.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]int64, 0)
|
||||
for rows.Next() {
|
||||
var v int64
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func revokeGroupGrantsForRemovedUsersTx(tx *store.Tx, userGroupID int64, previousUserIDs, currentUserIDs []int64) error {
|
||||
currentSet := make(map[int64]struct{}, len(currentUserIDs))
|
||||
for _, uid := range currentUserIDs {
|
||||
if uid > 0 {
|
||||
currentSet[uid] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
removedUserIDs := make([]int64, 0)
|
||||
for _, uid := range previousUserIDs {
|
||||
if uid <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := currentSet[uid]; !ok {
|
||||
removedUserIDs = append(removedUserIDs, uid)
|
||||
}
|
||||
}
|
||||
if len(removedUserIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, userID := range removedUserIDs {
|
||||
rows, err := tx.Query(`
|
||||
SELECT g.user_tunnel_id, g.created_by_group
|
||||
FROM group_permission_grant g
|
||||
JOIN user_tunnel ut ON ut.id = g.user_tunnel_id
|
||||
WHERE g.user_group_id = ? AND ut.user_id = ?
|
||||
`, userGroupID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
groupCreatedTunnelIDs := make(map[int64]struct{})
|
||||
for rows.Next() {
|
||||
var userTunnelID int64
|
||||
var createdByGroup int
|
||||
if err := rows.Scan(&userTunnelID, &createdByGroup); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if createdByGroup == 1 && userTunnelID > 0 {
|
||||
groupCreatedTunnelIDs[userTunnelID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if _, err := tx.Exec(`
|
||||
DELETE FROM group_permission_grant
|
||||
WHERE user_group_id = ?
|
||||
AND user_tunnel_id IN (SELECT id FROM user_tunnel WHERE user_id = ?)
|
||||
`, userGroupID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for userTunnelID := range groupCreatedTunnelIDs {
|
||||
var remaining int
|
||||
if err := tx.QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&remaining); err != nil {
|
||||
return err
|
||||
}
|
||||
if remaining == 0 {
|
||||
if _, err := tx.Exec(`DELETE FROM user_tunnel WHERE id = ?`, userTunnelID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func revokeGroupPermissionPairTx(tx *store.Tx, userGroupID, tunnelGroupID int64) error {
|
||||
rows, err := tx.Query(`
|
||||
SELECT user_tunnel_id, created_by_group
|
||||
FROM group_permission_grant
|
||||
WHERE user_group_id = ? AND tunnel_group_id = ?
|
||||
`, userGroupID, tunnelGroupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
groupCreatedTunnelIDs := make(map[int64]struct{})
|
||||
for rows.Next() {
|
||||
var userTunnelID int64
|
||||
var createdByGroup int
|
||||
if err := rows.Scan(&userTunnelID, &createdByGroup); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if createdByGroup == 1 && userTunnelID > 0 {
|
||||
groupCreatedTunnelIDs[userTunnelID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM group_permission_grant WHERE user_group_id = ? AND tunnel_group_id = ?`, userGroupID, tunnelGroupID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for userTunnelID := range groupCreatedTunnelIDs {
|
||||
var remaining int
|
||||
if err := tx.QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&remaining); err != nil {
|
||||
return err
|
||||
}
|
||||
if remaining == 0 {
|
||||
if _, err := tx.Exec(`DELETE FROM user_tunnel WHERE id = ?`, userTunnelID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func queryPairs(db *store.DB, q string, args ...interface{}) ([][2]int64, error) {
|
||||
rows, err := db.Query(q, args...)
|
||||
if err != nil {
|
||||
@@ -2058,7 +2233,7 @@ func (h *Handler) prepareTunnelCreateState(tx *store.Tx, req map[string]interfac
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if node.Status != 1 {
|
||||
if node.IsRemote != 1 && node.Status != 1 {
|
||||
return nil, errors.New("部分节点不在线")
|
||||
}
|
||||
state.Nodes[nodeID] = node
|
||||
@@ -2561,14 +2736,19 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu
|
||||
if port <= 0 {
|
||||
return nil, errors.New("节点端口不能为空")
|
||||
}
|
||||
protocol := defaultString(target.Protocol, "tls")
|
||||
connector := map[string]interface{}{
|
||||
"type": "relay",
|
||||
}
|
||||
if isTLSTunnelProtocol(protocol) {
|
||||
connector["metadata"] = map[string]interface{}{"nodelay": true}
|
||||
}
|
||||
nodeItems = append(nodeItems, map[string]interface{}{
|
||||
"name": fmt.Sprintf("node_%d", idx+1),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, port)),
|
||||
"connector": map[string]interface{}{
|
||||
"type": "relay",
|
||||
},
|
||||
"name": fmt.Sprintf("node_%d", idx+1),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, port)),
|
||||
"connector": connector,
|
||||
"dialer": map[string]interface{}{
|
||||
"type": defaultString(target.Protocol, "tls"),
|
||||
"type": protocol,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -2597,14 +2777,19 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
protocol := defaultString(chainNode.Protocol, "tls")
|
||||
handlerCfg := map[string]interface{}{
|
||||
"type": "relay",
|
||||
}
|
||||
if isTLSTunnelProtocol(protocol) {
|
||||
handlerCfg["metadata"] = map[string]interface{}{"nodelay": true}
|
||||
}
|
||||
service := map[string]interface{}{
|
||||
"name": fmt.Sprintf("%d_tls", tunnelID),
|
||||
"addr": fmt.Sprintf("%s:%d", node.TCPListenAddr, chainNode.Port),
|
||||
"handler": map[string]interface{}{
|
||||
"type": "relay",
|
||||
},
|
||||
"name": fmt.Sprintf("%d_tls", tunnelID),
|
||||
"addr": fmt.Sprintf("%s:%d", node.TCPListenAddr, chainNode.Port),
|
||||
"handler": handlerCfg,
|
||||
"listener": map[string]interface{}{
|
||||
"type": defaultString(chainNode.Protocol, "tls"),
|
||||
"type": protocol,
|
||||
},
|
||||
}
|
||||
if chainNode.ChainType == 2 {
|
||||
@@ -2650,6 +2835,10 @@ func nodeDisplayName(node *nodeRecord) string {
|
||||
return fmt.Sprintf("node_%d", node.ID)
|
||||
}
|
||||
|
||||
func isTLSTunnelProtocol(protocol string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(defaultString(protocol, "tls")), "tls")
|
||||
}
|
||||
|
||||
func nodeSupportsV4(node *nodeRecord) bool {
|
||||
if node == nil {
|
||||
return false
|
||||
@@ -2846,7 +3035,7 @@ func replaceTunnelChainsTx(tx *store.Tx, tunnelID int64, req map[string]interfac
|
||||
if nodeID <= 0 {
|
||||
continue
|
||||
}
|
||||
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, NULL, NULL, 0, ?)`,
|
||||
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, '1', ?, NULL, NULL, 0, ?)`,
|
||||
tunnelID, nodeID, defaultString(asString(n["protocol"]), "tls"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2865,7 +3054,7 @@ func replaceTunnelChainsTx(tx *store.Tx, tunnelID int64, req map[string]interfac
|
||||
return pickErr
|
||||
}
|
||||
}
|
||||
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 3, ?, ?, ?, 0, ?)`,
|
||||
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, '3', ?, ?, ?, 0, ?)`,
|
||||
tunnelID, nodeID, port, defaultString(asString(n["strategy"]), "round"), defaultString(asString(n["protocol"]), "tls"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2886,7 +3075,7 @@ func replaceTunnelChainsTx(tx *store.Tx, tunnelID int64, req map[string]interfac
|
||||
return pickErr
|
||||
}
|
||||
}
|
||||
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 2, ?, ?, ?, ?, ?)`,
|
||||
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, '2', ?, ?, ?, ?, ?)`,
|
||||
tunnelID, nodeID, port, defaultString(asString(n["strategy"]), "round"), i+1, defaultString(asString(n["protocol"]), "tls"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2972,7 +3161,7 @@ func (h *Handler) batchForwardStatus(ids []int64, status int) (int, int) {
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelEntryNodeIDs(tunnelID int64) ([]int64, error) {
|
||||
rows, err := h.repo.DB().Query(`SELECT node_id FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 1 ORDER BY inx ASC, id ASC`, tunnelID)
|
||||
rows, err := h.repo.DB().Query(`SELECT node_id FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = '1' ORDER BY inx ASC, id ASC`, tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ func shouldSkip(path string) bool {
|
||||
return true
|
||||
case path == "/api/v1/federation/runtime/diagnose":
|
||||
return true
|
||||
case path == "/api/v1/federation/runtime/command":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
+234
-48
@@ -4,7 +4,7 @@ package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -98,7 +98,7 @@ func (db *DB) Begin() (*Tx, error) {
|
||||
func (db *DB) ExecReturningID(query string, args ...any) (int64, error) {
|
||||
q := db.rewrite(query)
|
||||
if db.dialect == DialectPostgres {
|
||||
q = strings.TrimRight(q, "; \t\n") + " RETURNING id"
|
||||
q = ensureReturningID(q)
|
||||
var id int64
|
||||
if err := db.raw.QueryRow(q, args...).Scan(&id); err != nil {
|
||||
return 0, err
|
||||
@@ -143,7 +143,7 @@ func (tx *Tx) Rollback() error { return tx.raw.Rollback() }
|
||||
func (tx *Tx) ExecReturningID(query string, args ...any) (int64, error) {
|
||||
q := rewriteQuery(tx.dialect, query)
|
||||
if tx.dialect == DialectPostgres {
|
||||
q = strings.TrimRight(q, "; \t\n") + " RETURNING id"
|
||||
q = ensureReturningID(q)
|
||||
var id int64
|
||||
if err := tx.raw.QueryRow(q, args...).Scan(&id); err != nil {
|
||||
return 0, err
|
||||
@@ -174,35 +174,15 @@ func rewriteQuery(dialect Dialect, query string) string {
|
||||
func rewriteUserIdentifier(query string) string {
|
||||
var buf strings.Builder
|
||||
buf.Grow(len(query) + 16)
|
||||
inSingle := false
|
||||
inDouble := false
|
||||
i := 0
|
||||
for i < len(query) {
|
||||
ch := query[i]
|
||||
if ch == '\'' && !inDouble {
|
||||
if inSingle && i+1 < len(query) && query[i+1] == '\'' {
|
||||
buf.WriteByte(ch)
|
||||
buf.WriteByte(query[i+1])
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
inSingle = !inSingle
|
||||
buf.WriteByte(ch)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if ch == '"' && !inSingle {
|
||||
inDouble = !inDouble
|
||||
buf.WriteByte(ch)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if inSingle || inDouble {
|
||||
buf.WriteByte(ch)
|
||||
i++
|
||||
if end, ok := skipSQLProtectedSegment(query, i); ok {
|
||||
buf.WriteString(query[i:end])
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
ch := query[i]
|
||||
if isIdentifierChar(ch) {
|
||||
j := i + 1
|
||||
for j < len(query) && isIdentifierChar(query[j]) {
|
||||
@@ -238,39 +218,43 @@ func isIdentifierChar(ch byte) bool {
|
||||
}
|
||||
|
||||
func rewriteInsertOrIgnore(query string) string {
|
||||
upper := strings.ToUpper(query)
|
||||
idx := strings.Index(upper, "INSERT OR IGNORE INTO")
|
||||
if idx < 0 {
|
||||
start, end, ok := findKeywordSequenceOutside(query, []string{"INSERT", "OR", "IGNORE", "INTO"}, 0)
|
||||
if !ok {
|
||||
return query
|
||||
}
|
||||
prefix := query[:idx]
|
||||
suffix := query[idx+len("INSERT OR IGNORE INTO"):]
|
||||
result := prefix + "INSERT INTO" + suffix
|
||||
|
||||
trimmed := strings.TrimRight(result, "; \t\n")
|
||||
return trimmed + " ON CONFLICT DO NOTHING"
|
||||
rewritten := query[:start] + "INSERT INTO" + query[end:]
|
||||
rewritten = strings.TrimRight(rewritten, "; \t\n")
|
||||
|
||||
insertIntoEnd := start + len("INSERT INTO")
|
||||
if _, _, hasOnConflict := findKeywordSequenceOutside(rewritten, []string{"ON", "CONFLICT"}, insertIntoEnd); hasOnConflict {
|
||||
return rewritten
|
||||
}
|
||||
|
||||
if retStart, _, hasReturning := findKeywordSequenceOutside(rewritten, []string{"RETURNING"}, insertIntoEnd); hasReturning {
|
||||
prefix := strings.TrimRight(rewritten[:retStart], " \t\n")
|
||||
suffix := strings.TrimLeft(rewritten[retStart:], " \t\n")
|
||||
return prefix + " ON CONFLICT DO NOTHING " + suffix
|
||||
}
|
||||
|
||||
return rewritten + " ON CONFLICT DO NOTHING"
|
||||
}
|
||||
|
||||
func rewritePlaceholders(query string) string {
|
||||
var buf strings.Builder
|
||||
buf.Grow(len(query) + 16)
|
||||
n := 1
|
||||
inString := false
|
||||
for i := 0; i < len(query); i++ {
|
||||
ch := query[i]
|
||||
if ch == '\'' {
|
||||
if inString && i+1 < len(query) && query[i+1] == '\'' {
|
||||
buf.WriteByte(ch)
|
||||
buf.WriteByte(query[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
inString = !inString
|
||||
buf.WriteByte(ch)
|
||||
if end, ok := skipSQLProtectedSegment(query, i); ok {
|
||||
buf.WriteString(query[i:end])
|
||||
i = end - 1
|
||||
continue
|
||||
}
|
||||
if ch == '?' && !inString {
|
||||
buf.WriteString(fmt.Sprintf("$%d", n))
|
||||
|
||||
ch := query[i]
|
||||
if ch == '?' {
|
||||
buf.WriteByte('$')
|
||||
buf.WriteString(strconv.Itoa(n))
|
||||
n++
|
||||
continue
|
||||
}
|
||||
@@ -278,3 +262,205 @@ func rewritePlaceholders(query string) string {
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func ensureReturningID(query string) string {
|
||||
trimmed := strings.TrimRight(query, "; \t\n")
|
||||
if _, _, ok := findKeywordSequenceOutside(trimmed, []string{"RETURNING"}, 0); ok {
|
||||
return trimmed
|
||||
}
|
||||
return trimmed + " RETURNING id"
|
||||
}
|
||||
|
||||
func findKeywordSequenceOutside(query string, keywords []string, from int) (int, int, bool) {
|
||||
if len(keywords) == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
if from >= len(query) {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
matched := 0
|
||||
seqStart := -1
|
||||
|
||||
for i := from; i < len(query); {
|
||||
if end, ok := skipSQLProtectedSegment(query, i); ok {
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
ch := query[i]
|
||||
if isIdentifierChar(ch) {
|
||||
j := i + 1
|
||||
for j < len(query) && isIdentifierChar(query[j]) {
|
||||
j++
|
||||
}
|
||||
tok := query[i:j]
|
||||
|
||||
if strings.EqualFold(tok, keywords[matched]) {
|
||||
if matched == 0 {
|
||||
seqStart = i
|
||||
}
|
||||
matched++
|
||||
if matched == len(keywords) {
|
||||
return seqStart, j, true
|
||||
}
|
||||
} else if strings.EqualFold(tok, keywords[0]) {
|
||||
seqStart = i
|
||||
matched = 1
|
||||
} else {
|
||||
matched = 0
|
||||
seqStart = -1
|
||||
}
|
||||
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
if !isSQLSpace(ch) {
|
||||
matched = 0
|
||||
seqStart = -1
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func skipSQLProtectedSegment(query string, i int) (int, bool) {
|
||||
if i < 0 || i >= len(query) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
switch query[i] {
|
||||
case '\'':
|
||||
return skipSingleQuotedLiteral(query, i), true
|
||||
case '"':
|
||||
return skipDoubleQuotedIdentifier(query, i), true
|
||||
case '-':
|
||||
if i+1 < len(query) && query[i+1] == '-' {
|
||||
return skipLineComment(query, i), true
|
||||
}
|
||||
case '/':
|
||||
if i+1 < len(query) && query[i+1] == '*' {
|
||||
return skipBlockComment(query, i), true
|
||||
}
|
||||
case '$':
|
||||
if end, ok := skipDollarQuotedLiteral(query, i); ok {
|
||||
return end, true
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func skipSingleQuotedLiteral(query string, i int) int {
|
||||
for j := i + 1; j < len(query); j++ {
|
||||
if query[j] != '\'' {
|
||||
continue
|
||||
}
|
||||
if j+1 < len(query) && query[j+1] == '\'' {
|
||||
j++
|
||||
continue
|
||||
}
|
||||
return j + 1
|
||||
}
|
||||
return len(query)
|
||||
}
|
||||
|
||||
func skipDoubleQuotedIdentifier(query string, i int) int {
|
||||
for j := i + 1; j < len(query); j++ {
|
||||
if query[j] != '"' {
|
||||
continue
|
||||
}
|
||||
if j+1 < len(query) && query[j+1] == '"' {
|
||||
j++
|
||||
continue
|
||||
}
|
||||
return j + 1
|
||||
}
|
||||
return len(query)
|
||||
}
|
||||
|
||||
func skipLineComment(query string, i int) int {
|
||||
for j := i + 2; j < len(query); j++ {
|
||||
if query[j] == '\n' {
|
||||
return j
|
||||
}
|
||||
}
|
||||
return len(query)
|
||||
}
|
||||
|
||||
func skipBlockComment(query string, i int) int {
|
||||
depth := 1
|
||||
for j := i + 2; j < len(query)-1; j++ {
|
||||
if query[j] == '/' && query[j+1] == '*' {
|
||||
depth++
|
||||
j++
|
||||
continue
|
||||
}
|
||||
if query[j] == '*' && query[j+1] == '/' {
|
||||
depth--
|
||||
j++
|
||||
if depth == 0 {
|
||||
return j + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return len(query)
|
||||
}
|
||||
|
||||
func skipDollarQuotedLiteral(query string, i int) (int, bool) {
|
||||
if i < 0 || i >= len(query) || query[i] != '$' {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if i+1 >= len(query) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var endTag int
|
||||
if query[i+1] == '$' {
|
||||
endTag = i + 1
|
||||
} else {
|
||||
if !isDollarTagStart(query[i+1]) {
|
||||
return 0, false
|
||||
}
|
||||
j := i + 2
|
||||
for j < len(query) && isDollarTagChar(query[j]) {
|
||||
j++
|
||||
}
|
||||
if j >= len(query) || query[j] != '$' {
|
||||
return 0, false
|
||||
}
|
||||
endTag = j
|
||||
}
|
||||
|
||||
tag := query[i : endTag+1]
|
||||
if closeIdx := strings.Index(query[endTag+1:], tag); closeIdx >= 0 {
|
||||
return endTag + 1 + closeIdx + len(tag), true
|
||||
}
|
||||
return len(query), true
|
||||
}
|
||||
|
||||
func isDollarTagStart(ch byte) bool {
|
||||
return ch == '_' || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
|
||||
}
|
||||
|
||||
func isDollarTagChar(ch byte) bool {
|
||||
if isDollarTagStart(ch) {
|
||||
return true
|
||||
}
|
||||
return ch >= '0' && ch <= '9'
|
||||
}
|
||||
|
||||
func isSQLSpace(ch byte) bool {
|
||||
switch ch {
|
||||
case ' ', '\t', '\n', '\r', '\f':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRewritePlaceholdersSkipsProtectedSegments(t *testing.T) {
|
||||
q := `SELECT ?, '?', "id?", $$body ? $$, $tag$X?$tag$, col -- comment ?
|
||||
FROM t /* block ? */ WHERE id = ?`
|
||||
got := rewritePlaceholders(q)
|
||||
want := `SELECT $1, '?', "id?", $$body ? $$, $tag$X?$tag$, col -- comment ?
|
||||
FROM t /* block ? */ WHERE id = $2`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteInsertOrIgnoreBasic(t *testing.T) {
|
||||
q := `INSERT OR IGNORE INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?)`
|
||||
got := rewriteInsertOrIgnore(q)
|
||||
want := `INSERT INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteInsertOrIgnoreBeforeReturning(t *testing.T) {
|
||||
q := `INSERT OR IGNORE INTO x(a) VALUES(?) RETURNING id`
|
||||
got := rewriteInsertOrIgnore(q)
|
||||
want := `INSERT INTO x(a) VALUES(?) ON CONFLICT DO NOTHING RETURNING id`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteInsertOrIgnoreNotDuplicatingOnConflict(t *testing.T) {
|
||||
q := `INSERT OR IGNORE INTO x(a) VALUES(?) ON CONFLICT(a) DO UPDATE SET a=excluded.a`
|
||||
got := rewriteInsertOrIgnore(q)
|
||||
want := `INSERT INTO x(a) VALUES(?) ON CONFLICT(a) DO UPDATE SET a=excluded.a`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReturningID(t *testing.T) {
|
||||
if got := ensureReturningID(`INSERT INTO x(a) VALUES($1)`); got != `INSERT INTO x(a) VALUES($1) RETURNING id` {
|
||||
t.Fatalf("missing RETURNING append: %s", got)
|
||||
}
|
||||
if got := ensureReturningID(`INSERT INTO x(a) VALUES($1) RETURNING other_id`); got != `INSERT INTO x(a) VALUES($1) RETURNING other_id` {
|
||||
t.Fatalf("RETURNING should not be duplicated: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteUserIdentifierSafety(t *testing.T) {
|
||||
q := `SELECT user, user_id, 'user', "user", note FROM user -- user
|
||||
WHERE owner='user'`
|
||||
got := rewriteUserIdentifier(q)
|
||||
want := `SELECT "user", user_id, 'user', "user", note FROM "user" -- user
|
||||
WHERE owner='user'`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteQueryPostgresPipeline(t *testing.T) {
|
||||
q := `INSERT OR IGNORE INTO user(name, note) VALUES(?, '?')`
|
||||
got := rewriteQuery(DialectPostgres, q)
|
||||
want := `INSERT INTO "user"(name, note) VALUES($1, '?') ON CONFLICT DO NOTHING`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteInsertOrIgnoreSkipsStringLiteral(t *testing.T) {
|
||||
q := `SELECT 'INSERT OR IGNORE INTO t(a) VALUES(?)' AS q`
|
||||
got := rewriteInsertOrIgnore(q)
|
||||
if got != q {
|
||||
t.Fatalf("string literal should stay unchanged\nwant: %s\ngot: %s", q, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteInsertOrIgnoreSkipsCommentedKeyword(t *testing.T) {
|
||||
q := `-- INSERT OR IGNORE INTO ignored(a) VALUES(?)
|
||||
INSERT OR IGNORE INTO real_t(a) VALUES(?)`
|
||||
got := rewriteInsertOrIgnore(q)
|
||||
want := `-- INSERT OR IGNORE INTO ignored(a) VALUES(?)
|
||||
INSERT INTO real_t(a) VALUES(?) ON CONFLICT DO NOTHING`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewritePlaceholdersSkipsNestedBlockComment(t *testing.T) {
|
||||
q := `SELECT ? /* outer ? /* inner ? */ still_outer ? */ FROM t WHERE id = ?`
|
||||
got := rewritePlaceholders(q)
|
||||
want := `SELECT $1 /* outer ? /* inner ? */ still_outer ? */ FROM t WHERE id = $2`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewritePlaceholdersSkipsUnterminatedBlockComment(t *testing.T) {
|
||||
q := `SELECT ? /* unterminated ? comment`
|
||||
got := rewritePlaceholders(q)
|
||||
want := `SELECT $1 /* unterminated ? comment`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteUserIdentifierSkipsDollarQuotedAndComment(t *testing.T) {
|
||||
q := `SELECT user, $$user ?$$ AS body, col FROM user /* user */ -- user`
|
||||
got := rewriteUserIdentifier(q)
|
||||
want := `SELECT "user", $$user ?$$ AS body, col FROM "user" /* user */ -- user`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,8 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token")
|
||||
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-token")
|
||||
|
||||
stopEntry := startMockNodeSession(t, providerServer.URL, "provider-entry-secret")
|
||||
defer stopEntry()
|
||||
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-secret")
|
||||
defer stopMiddle()
|
||||
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-secret")
|
||||
@@ -148,6 +150,23 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 2, middleRemoteNodeID, 44000, 44010)
|
||||
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 3, exitRemoteNodeID, 45000, 45010)
|
||||
|
||||
forwardPayload := map[string]interface{}{
|
||||
"name": "dual-panel-remote-entry-forward",
|
||||
"tunnelId": secondTunnelID,
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
forwardBody, err := json.Marshal(forwardPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal forward payload: %v", err)
|
||||
}
|
||||
forwardReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(forwardBody))
|
||||
forwardReq.Header.Set("Authorization", consumerAdminToken)
|
||||
forwardReq.Header.Set("Content-Type", "application/json")
|
||||
forwardRes := httptest.NewRecorder()
|
||||
consumerRouter.ServeHTTP(forwardRes, forwardReq)
|
||||
assertCode(t, forwardRes, 0)
|
||||
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, middleShareID, 1)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, exitShareID, 1)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, entryShareID, 0)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
)
|
||||
|
||||
func TestGroupUserUnbindRevokesInheritedTunnelPermission(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(200, 'group_user_contract', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
t.Fatalf("insert test user: %v", err)
|
||||
}
|
||||
|
||||
tunnelRes, err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES('group-contract-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := tunnelRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read tunnel id: %v", err)
|
||||
}
|
||||
|
||||
ugRes, err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-contract', ?, ?, 1)`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert user_group: %v", err)
|
||||
}
|
||||
userGroupID, err := ugRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read user_group id: %v", err)
|
||||
}
|
||||
|
||||
tgRes, err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-contract', ?, ?, 1)`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel_group: %v", err)
|
||||
}
|
||||
tunnelGroupID, err := tgRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read tunnel_group id: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO tunnel_group_tunnel(tunnel_group_id, tunnel_id, created_time) VALUES(?, ?, ?)`, tunnelGroupID, tunnelID, now); err != nil {
|
||||
t.Fatalf("insert tunnel_group_tunnel: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`INSERT INTO group_permission(user_group_id, tunnel_group_id, created_time) VALUES(?, ?, ?)`, userGroupID, tunnelGroupID, now); err != nil {
|
||||
t.Fatalf("insert group_permission: %v", err)
|
||||
}
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
bindReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[200]}`))
|
||||
bindReq.Header.Set("Authorization", adminToken)
|
||||
bindRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(bindRes, bindReq)
|
||||
assertCode(t, bindRes, 0)
|
||||
|
||||
var userTunnelID int64
|
||||
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 200 AND tunnel_id = ?`, tunnelID).Scan(&userTunnelID); err != nil {
|
||||
t.Fatalf("query user_tunnel after bind: %v", err)
|
||||
}
|
||||
|
||||
var grantCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||
t.Fatalf("query group_permission_grant after bind: %v", err)
|
||||
}
|
||||
if grantCount == 0 {
|
||||
t.Fatalf("expected non-zero grants after bind")
|
||||
}
|
||||
|
||||
unbindReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[]}`))
|
||||
unbindReq.Header.Set("Authorization", adminToken)
|
||||
unbindRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(unbindRes, unbindReq)
|
||||
assertCode(t, unbindRes, 0)
|
||||
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||
t.Fatalf("query group_permission_grant after unbind: %v", err)
|
||||
}
|
||||
if grantCount != 0 {
|
||||
t.Fatalf("expected grants revoked after unbind, got %d", grantCount)
|
||||
}
|
||||
|
||||
var userTunnelCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID).Scan(&userTunnelCount); err != nil {
|
||||
t.Fatalf("query user_tunnel after unbind: %v", err)
|
||||
}
|
||||
if userTunnelCount != 0 {
|
||||
t.Fatalf("expected user_tunnel revoked after unbind, got %d", userTunnelCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupPermissionRemoveRevokesInheritedTunnelPermission(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(201, 'group_user_permission_remove', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
t.Fatalf("insert test user: %v", err)
|
||||
}
|
||||
|
||||
tunnelRes, err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES('group-remove-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := tunnelRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read tunnel id: %v", err)
|
||||
}
|
||||
|
||||
ugRes, err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-remove-contract', ?, ?, 1)`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert user_group: %v", err)
|
||||
}
|
||||
userGroupID, err := ugRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read user_group id: %v", err)
|
||||
}
|
||||
|
||||
tgRes, err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-remove-contract', ?, ?, 1)`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel_group: %v", err)
|
||||
}
|
||||
tunnelGroupID, err := tgRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read tunnel_group id: %v", err)
|
||||
}
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
assignTunnelReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/tunnel/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(tunnelGroupID)+`,"tunnelIds":[`+jsonNumber(tunnelID)+`]}`))
|
||||
assignTunnelReq.Header.Set("Authorization", adminToken)
|
||||
assignTunnelRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(assignTunnelRes, assignTunnelReq)
|
||||
assertCode(t, assignTunnelRes, 0)
|
||||
|
||||
assignUserReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[201]}`))
|
||||
assignUserReq.Header.Set("Authorization", adminToken)
|
||||
assignUserRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(assignUserRes, assignUserReq)
|
||||
assertCode(t, assignUserRes, 0)
|
||||
|
||||
assignPermissionReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/permission/assign", bytes.NewBufferString(`{"userGroupId":`+jsonNumber(userGroupID)+`,"tunnelGroupId":`+jsonNumber(tunnelGroupID)+`}`))
|
||||
assignPermissionReq.Header.Set("Authorization", adminToken)
|
||||
assignPermissionRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(assignPermissionRes, assignPermissionReq)
|
||||
assertCode(t, assignPermissionRes, 0)
|
||||
|
||||
var permissionID int64
|
||||
if err := repo.DB().QueryRow(`SELECT id FROM group_permission WHERE user_group_id = ? AND tunnel_group_id = ?`, userGroupID, tunnelGroupID).Scan(&permissionID); err != nil {
|
||||
t.Fatalf("query group_permission id: %v", err)
|
||||
}
|
||||
|
||||
var userTunnelID int64
|
||||
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 201 AND tunnel_id = ?`, tunnelID).Scan(&userTunnelID); err != nil {
|
||||
t.Fatalf("query user_tunnel after assign: %v", err)
|
||||
}
|
||||
|
||||
var grantCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||
t.Fatalf("query group_permission_grant after assign: %v", err)
|
||||
}
|
||||
if grantCount == 0 {
|
||||
t.Fatalf("expected non-zero grants after permission assign")
|
||||
}
|
||||
|
||||
removeReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/permission/remove", bytes.NewBufferString(`{"id":`+jsonNumber(permissionID)+`}`))
|
||||
removeReq.Header.Set("Authorization", adminToken)
|
||||
removeRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(removeRes, removeReq)
|
||||
assertCode(t, removeRes, 0)
|
||||
|
||||
var permissionCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission WHERE id = ?`, permissionID).Scan(&permissionCount); err != nil {
|
||||
t.Fatalf("query group_permission after remove: %v", err)
|
||||
}
|
||||
if permissionCount != 0 {
|
||||
t.Fatalf("expected group_permission removed, got %d", permissionCount)
|
||||
}
|
||||
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||
t.Fatalf("query group_permission_grant after remove: %v", err)
|
||||
}
|
||||
if grantCount != 0 {
|
||||
t.Fatalf("expected grants removed after permission remove, got %d", grantCount)
|
||||
}
|
||||
|
||||
var userTunnelCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID).Scan(&userTunnelCount); err != nil {
|
||||
t.Fatalf("query user_tunnel after permission remove: %v", err)
|
||||
}
|
||||
if userTunnelCount != 0 {
|
||||
t.Fatalf("expected user_tunnel revoked after permission remove, got %d", userTunnelCount)
|
||||
}
|
||||
}
|
||||
@@ -54,10 +54,10 @@ var needWrap = false
|
||||
|
||||
// SetProtocolBlock sets protocol blocking switches and recomputes wrapper need
|
||||
func SetProtocolBlock(httpOn int, tlsOn int, socksOn int) {
|
||||
isHttp = httpOn
|
||||
isTls = tlsOn
|
||||
isSocks = socksOn
|
||||
needWrap = isTls+isSocks+isHttp > 0
|
||||
isHttp = httpOn
|
||||
isTls = tlsOn
|
||||
isSocks = socksOn
|
||||
needWrap = isTls+isSocks+isHttp > 0
|
||||
}
|
||||
|
||||
type Option func(opts *options)
|
||||
@@ -292,7 +292,9 @@ func (s *defaultService) Serve() error {
|
||||
}
|
||||
|
||||
if err := s.handler.Handle(ctx, conn); err != nil {
|
||||
log.Error(err)
|
||||
if !errors.Is(err, net.ErrClosed) {
|
||||
log.Error(err)
|
||||
}
|
||||
if v := xmetrics.GetCounter(xmetrics.MetricServiceHandlerErrorsCounter,
|
||||
metrics.Labels{"service": s.name, "client": clientIP}); v != nil {
|
||||
v.Inc()
|
||||
@@ -403,12 +405,12 @@ func (s *defaultService) observeStats(ctx context.Context) {
|
||||
TotalErrs: st.Get(stats.KindTotalErrs),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
// 将流量累积到全局管理器,而不是立即上报
|
||||
if outputBytes > 0 || inputBytes > 0 {
|
||||
globalManager := GetGlobalTrafficManager()
|
||||
globalManager.AddTraffic(s.name, int64(outputBytes), int64(inputBytes))
|
||||
|
||||
|
||||
// 立即重置流量计数(因为已经记录到全局管理器中)
|
||||
if xstats, ok := st.(*xstats.Stats); ok {
|
||||
xstats.ResetTraffic(st.Get(stats.KindInputBytes)-inputBytes, st.Get(stats.KindOutputBytes)-outputBytes)
|
||||
|
||||
@@ -228,3 +228,46 @@ export const importRemoteNode = (data: {
|
||||
remoteUrl: string;
|
||||
token: string;
|
||||
}) => Network.post("/federation/node/import", data);
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
export interface BackupTypes {
|
||||
users?: boolean;
|
||||
nodes?: boolean;
|
||||
tunnels?: boolean;
|
||||
forwards?: boolean;
|
||||
userTunnels?: boolean;
|
||||
speedLimits?: boolean;
|
||||
tunnelGroups?: boolean;
|
||||
userGroups?: boolean;
|
||||
permissions?: boolean;
|
||||
configs?: boolean;
|
||||
}
|
||||
|
||||
export const exportBackup = async (types: string[] = []) => {
|
||||
const token = window.localStorage.getItem("token");
|
||||
const baseURL = axios.defaults.baseURL || "/api/v1/";
|
||||
|
||||
const response = await axios.post(`${baseURL}/backup/export`, { types }, {
|
||||
headers: {
|
||||
Authorization: token,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, "");
|
||||
link.setAttribute("download", `backup_${timestamp}.json`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const importBackup = (data: {
|
||||
types: string[];
|
||||
[key: string]: any;
|
||||
}) => Network.post("/backup/import", data);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
@@ -7,9 +7,10 @@ import { Spinner } from "@heroui/spinner";
|
||||
import { Divider } from "@heroui/divider";
|
||||
import { Switch } from "@heroui/switch";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { Checkbox, CheckboxGroup } from "@heroui/checkbox";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { updateConfigs } from "@/api";
|
||||
import { updateConfigs, exportBackup, importBackup } from "@/api";
|
||||
import { SettingsIcon } from "@/components/icons";
|
||||
import { isAdmin } from "@/utils/auth";
|
||||
import {
|
||||
@@ -130,12 +131,19 @@ export default function ConfigPage() {
|
||||
useState<Record<string, string>>(initialConfigs);
|
||||
const [loading, setLoading] = useState(
|
||||
Object.keys(initialConfigs).length === 0,
|
||||
); // 如果有缓存数据,不显示loading
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [originalConfigs, setOriginalConfigs] =
|
||||
useState<Record<string, string>>(initialConfigs);
|
||||
|
||||
const [exportTypes, setExportTypes] = useState<string[]>([]);
|
||||
const [importTypes, setImportTypes] = useState<string[]>([]);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importFileName, setImportFileName] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 权限检查
|
||||
useEffect(() => {
|
||||
if (!isAdmin()) {
|
||||
@@ -331,6 +339,60 @@ export default function ConfigPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (exportTypes.length === 0) {
|
||||
toast.error("请至少选择一种数据类型");
|
||||
return;
|
||||
}
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportBackup(exportTypes);
|
||||
toast.success("导出成功");
|
||||
} catch {
|
||||
toast.error("导出失败,请重试");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (importTypes.length === 0) {
|
||||
toast.error("请先选择要导入的数据类型");
|
||||
return;
|
||||
}
|
||||
|
||||
setImportFileName(file.name);
|
||||
setImporting(true);
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
const response = await importBackup({
|
||||
types: importTypes,
|
||||
...data,
|
||||
});
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success(`导入成功: ${JSON.stringify(response.data)}`);
|
||||
setImportTypes([]);
|
||||
setImportFileName("");
|
||||
} else {
|
||||
toast.error("导入失败: " + response.msg);
|
||||
}
|
||||
} catch {
|
||||
toast.error("导入失败,请检查文件格式");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -427,6 +489,143 @@ export default function ConfigPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 备份与恢复 */}
|
||||
<Card className="mt-6 shadow-md">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">数据备份与恢复</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
导出或导入系统数据,支持选择特定数据类型
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<Divider />
|
||||
|
||||
<CardBody className="space-y-6 pt-6">
|
||||
{/* 导出部分 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">导出数据</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
选择要导出的数据类型,导出为 JSON 格式文件
|
||||
</p>
|
||||
|
||||
<CheckboxGroup
|
||||
label="选择导出内容"
|
||||
orientation="horizontal"
|
||||
classNames={{
|
||||
wrapper: "gap-4",
|
||||
}}
|
||||
value={exportTypes}
|
||||
onValueChange={(values) => setExportTypes(values as string[])}
|
||||
>
|
||||
<Checkbox value="users">用户</Checkbox>
|
||||
<Checkbox value="nodes">节点</Checkbox>
|
||||
<Checkbox value="tunnels">隧道</Checkbox>
|
||||
<Checkbox value="forwards">转发</Checkbox>
|
||||
<Checkbox value="userTunnels">用户隧道权限</Checkbox>
|
||||
<Checkbox value="speedLimits">限速规则</Checkbox>
|
||||
<Checkbox value="tunnelGroups">隧道分组</Checkbox>
|
||||
<Checkbox value="userGroups">用户分组</Checkbox>
|
||||
<Checkbox value="permissions">分组权限</Checkbox>
|
||||
<Checkbox value="configs">系统配置</Checkbox>
|
||||
</CheckboxGroup>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={exporting}
|
||||
onPress={handleExport}
|
||||
>
|
||||
{exporting ? "导出中..." : "导出数据"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
onPress={() => {
|
||||
setExportTypes([
|
||||
"users",
|
||||
"nodes",
|
||||
"tunnels",
|
||||
"forwards",
|
||||
"userTunnels",
|
||||
"speedLimits",
|
||||
"tunnelGroups",
|
||||
"userGroups",
|
||||
"permissions",
|
||||
"configs",
|
||||
]);
|
||||
}}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
variant="flat"
|
||||
onPress={() => setExportTypes([])}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 导入部分 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">导入数据</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
选择要导入的数据类型,支持从备份文件恢复数据
|
||||
</p>
|
||||
|
||||
<CheckboxGroup
|
||||
label="选择导入内容"
|
||||
orientation="horizontal"
|
||||
classNames={{
|
||||
wrapper: "gap-4",
|
||||
}}
|
||||
value={importTypes}
|
||||
onValueChange={(values) => setImportTypes(values as string[])}
|
||||
>
|
||||
<Checkbox value="users">用户</Checkbox>
|
||||
<Checkbox value="nodes">节点</Checkbox>
|
||||
<Checkbox value="tunnels">隧道</Checkbox>
|
||||
<Checkbox value="forwards">转发</Checkbox>
|
||||
<Checkbox value="userTunnels">用户隧道权限</Checkbox>
|
||||
<Checkbox value="speedLimits">限速规则</Checkbox>
|
||||
<Checkbox value="tunnelGroups">隧道分组</Checkbox>
|
||||
<Checkbox value="userGroups">用户分组</Checkbox>
|
||||
<Checkbox value="permissions">分组权限</Checkbox>
|
||||
<Checkbox value="configs">系统配置</Checkbox>
|
||||
</CheckboxGroup>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
color="primary"
|
||||
variant="flat"
|
||||
isLoading={importing}
|
||||
onPress={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{importing ? "导入中..." : "选择文件导入"}
|
||||
</Button>
|
||||
{importFileName && (
|
||||
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
|
||||
已选择: {importFileName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user