Compare commits

..

13 Commits

Author SHA1 Message Date
sagit fea1bf52f3 Merge pull request #102 from Sagit-chu/opencode/quick-comet
feat: 导入导出备份
2026-02-13 14:39:25 +08:00
sagit a72d84fa76 Merge branch 'main' into opencode/quick-comet 2026-02-13 14:21:00 +08:00
sagit 146821ebba Merge pull request #104 from Sagit-chu/opencode/tidy-cactus
fix: 修复共享节点作为入口的问题和调整docker 网络
2026-02-13 14:16:56 +08:00
sagit 0191f29cf1 chore(docker): update network subnet in compose files
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

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

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

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 06:01:42 +00:00
sagit 51660c413e Merge branch 'main' into opencode/quick-comet 2026-02-13 13:46:31 +08:00
sagit 2d2ca389e3 fix(backup): add transaction support and auto-backup before import
- Add transaction support for import operations with rollback on failure
- Add auto-backup before import to allow recovery on failure
- Convert user import to use INSERT ON CONFLICT pattern
- Add Execer interface to support both DB and Tx in import functions
2026-02-13 05:44:50 +00:00
sagit dd206ced14 fix(permission): revoke inherited tunnel access after group unbind/removal 2026-02-13 03:28:21 +00:00
sagit f720b92f53 Merge branch 'main' into opencode/quick-comet 2026-02-13 11:10:09 +08:00
sagit f879a58bb4 feat(frontend): add backup export and import UI
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 03:06:23 +00:00
14 changed files with 2018 additions and 10 deletions
+1 -1
View File
@@ -87,4 +87,4 @@ networks:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
- subnet: 172.80.0.0/16
+1 -1
View File
@@ -88,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
@@ -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"`
@@ -1199,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)
@@ -1140,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))
}
+176 -4
View File
@@ -1713,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
@@ -1748,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())
}
@@ -1908,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 {
@@ -2061,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
@@ -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
}
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)
}
}
+43
View File
@@ -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);
+202 -3
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@heroui/button";
import { Card, CardBody, CardHeader } from "@heroui/card";
@@ -7,9 +7,10 @@ import { Spinner } from "@heroui/spinner";
import { Divider } from "@heroui/divider";
import { Switch } from "@heroui/switch";
import { Select, SelectItem } from "@heroui/select";
import { Checkbox, CheckboxGroup } from "@heroui/checkbox";
import toast from "react-hot-toast";
import { updateConfigs } from "@/api";
import { updateConfigs, exportBackup, importBackup } from "@/api";
import { SettingsIcon } from "@/components/icons";
import { isAdmin } from "@/utils/auth";
import {
@@ -130,12 +131,19 @@ export default function ConfigPage() {
useState<Record<string, string>>(initialConfigs);
const [loading, setLoading] = useState(
Object.keys(initialConfigs).length === 0,
); // 如果有缓存数据,不显示loading
);
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [originalConfigs, setOriginalConfigs] =
useState<Record<string, string>>(initialConfigs);
const [exportTypes, setExportTypes] = useState<string[]>([]);
const [importTypes, setImportTypes] = useState<string[]>([]);
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [importFileName, setImportFileName] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
// 权限检查
useEffect(() => {
if (!isAdmin()) {
@@ -331,6 +339,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>
);
}