Compare commits

...

16 Commits

Author SHA1 Message Date
sagit 5f42c50689 Merge pull request #63 from Sagit-chu/fix/backend-schema-migration
fix(backend): ensure all tables exist on startup via schema.sql
2026-02-09 20:43:21 +08:00
sagit 2b2f914bec feat(frontend): add panel sharing entry to mobile profile page
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-09 12:41:58 +00:00
sagit c18703eba2 fix(backend): ensure all tables exist on startup via schema.sql
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-09 12:30:40 +00:00
sagit 3f2150828d Merge pull request #49 from Sagit-chu/opencode/silent-wizard
feat: 共享面板
2026-02-09 19:46:11 +08:00
sagit c76bb77b93 Merge branch 'main' into opencode/silent-wizard 2026-02-09 19:42:48 +08:00
sagit 01d5c25df1 feat(frontend): add grouping management to mobile profile menu 2026-02-09 11:24:03 +00:00
sagit a19e8d2bcb feat: implement panel domain verification for federation sharing 2026-02-09 10:53:58 +00:00
sagit bf88b0dd7e Merge branch 'main' into opencode/silent-wizard 2026-02-09 14:35:25 +08:00
sagit 169df953d8 fix: resolve compilation error in repository.go 2026-02-09 03:02:11 +00:00
sagit 7bc33f63ba feat: complete federation sharing backend implementation 2026-02-09 03:00:18 +00:00
sagit 5925a84a6d Merge branch 'main' into opencode/silent-wizard 2026-02-09 10:59:10 +08:00
sagit 52885d1821 chore: update project state 2026-02-08 12:28:46 +00:00
sagit 124cf1ced2 Merge branch 'main' into opencode/silent-wizard 2026-02-08 20:27:25 +08:00
sagit fef4c28777 fix: frontend build errors and dependency issues 2026-02-08 10:45:18 +00:00
sagit 9ab5140258 chore: cleanup temporary files 2026-02-08 09:08:32 +00:00
sagit 11dc21e46f feat: implement consumer side panel peering logic 2026-02-08 09:03:56 +00:00
16 changed files with 1194 additions and 19 deletions
BIN
View File
Binary file not shown.
@@ -0,0 +1,121 @@
package client
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type FederationClient struct {
client *http.Client
}
type RemoteNodeInfo struct {
ShareID int64 `json:"shareId"`
ShareName string `json:"shareName"`
NodeID int64 `json:"nodeId"`
NodeName string `json:"nodeName"`
ServerIP string `json:"serverIp"`
Status int `json:"status"`
MaxBandwidth int64 `json:"maxBandwidth"`
ExpiryTime int64 `json:"expiryTime"`
PortRangeStart int `json:"portRangeStart"`
PortRangeEnd int `json:"portRangeEnd"`
}
type RemoteTunnelResponse struct {
TunnelID int64 `json:"tunnelId"`
}
func NewFederationClient() *FederationClient {
return &FederationClient{
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (c *FederationClient) Connect(url, token, localDomain string) (*RemoteNodeInfo, error) {
url = strings.TrimSuffix(url, "/")
req, err := http.NewRequest("POST", url+"/api/v1/federation/connect", nil)
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 RemoteNodeInfo `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
}
func (c *FederationClient) CreateTunnel(url, token, localDomain, protocol string, remotePort int, target string) (*RemoteTunnelResponse, error) {
url = strings.TrimSuffix(url, "/")
payload := map[string]interface{}{
"protocol": protocol,
"remotePort": remotePort,
"target": target,
}
bodyBytes, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", url+"/api/v1/federation/tunnel/create", 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 RemoteTunnelResponse `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}
if res.Code != 0 {
return nil, fmt.Errorf("remote api error: %s", res.Msg)
}
return &res.Data, nil
}
@@ -0,0 +1,396 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"go-backend/internal/http/client"
"go-backend/internal/http/response"
"go-backend/internal/store/sqlite"
)
type federationTunnelRequest struct {
Protocol string `json:"protocol"`
RemotePort int `json:"remotePort"`
Target string `json:"target"`
}
type createPeerShareRequest struct {
Name string `json:"name"`
NodeID int64 `json:"nodeId"`
MaxBandwidth int64 `json:"maxBandwidth"`
ExpiryTime int64 `json:"expiryTime"`
PortRangeStart int `json:"portRangeStart"`
PortRangeEnd int `json:"portRangeEnd"`
AllowedDomains string `json:"allowedDomains"`
}
type deletePeerShareRequest struct {
ID int64 `json:"id"`
}
type nodeImportRequest struct {
RemoteURL string `json:"remoteUrl"`
Token string `json:"token"`
}
func (h *Handler) federationShareList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
return
}
shares, err := h.repo.ListPeerShares()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(shares))
}
func (h *Handler) federationShareCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
return
}
var req createPeerShareRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
return
}
if req.Name == "" || req.NodeID == 0 {
response.WriteJSON(w, response.ErrDefault("Name and NodeID are required"))
return
}
if req.MaxBandwidth < 0 {
response.WriteJSON(w, response.ErrDefault("Max bandwidth cannot be negative"))
return
}
if req.ExpiryTime < 0 {
response.WriteJSON(w, response.ErrDefault("Expiry time cannot be negative"))
return
}
if req.PortRangeStart < 0 || req.PortRangeStart > 65535 || req.PortRangeEnd < 0 || req.PortRangeEnd > 65535 {
response.WriteJSON(w, response.ErrDefault("Invalid port range"))
return
}
if req.PortRangeStart > req.PortRangeEnd {
response.WriteJSON(w, response.ErrDefault("Port range start cannot be greater than end"))
return
}
node, err := h.repo.GetNodeByID(req.NodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if node == nil {
response.WriteJSON(w, response.ErrDefault("Node not found"))
return
}
now := time.Now().UnixMilli()
token := randomToken(32)
share := &sqlite.PeerShare{
Name: req.Name,
NodeID: req.NodeID,
Token: token,
MaxBandwidth: req.MaxBandwidth,
ExpiryTime: req.ExpiryTime,
PortRangeStart: req.PortRangeStart,
PortRangeEnd: req.PortRangeEnd,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
AllowedDomains: req.AllowedDomains,
}
if err := h.repo.CreatePeerShare(share); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) federationShareDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
return
}
var req deletePeerShareRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
return
}
if err := h.repo.DeletePeerShare(req.ID); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
return
}
var req nodeImportRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
return
}
if req.RemoteURL == "" || req.Token == "" {
response.WriteJSON(w, response.ErrDefault("Remote URL and Token are required"))
return
}
domainCfg, _ := h.repo.GetConfigByName("panel_domain")
localDomain := ""
if domainCfg != nil {
localDomain = domainCfg.Value
}
fc := client.NewFederationClient()
info, err := fc.Connect(req.RemoteURL, req.Token, localDomain)
if err != nil {
response.WriteJSON(w, response.Err(-2, "Failed to connect: "+err.Error()))
return
}
// Prepare config json for local storage (metadata about limits)
configData := map[string]interface{}{
"shareId": info.ShareID,
"maxBandwidth": info.MaxBandwidth,
"expiryTime": info.ExpiryTime,
"portRangeStart": info.PortRangeStart,
"portRangeEnd": info.PortRangeEnd,
}
configBytes, _ := json.Marshal(configData)
db := h.repo.DB()
inx := nextIndex(db, "node")
now := time.Now().UnixMilli()
_, err = 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(?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
`,
fmt.Sprintf("%s (Remote)", info.NodeName),
randomToken(16), // Dummy secret
info.ServerIP,
"", "", // v4/v6 unknown, use server_ip
"0", // port range not applicable for remote
"",
"",
now, now,
info.Status,
"[::]", "[::]",
inx,
req.RemoteURL,
req.Token,
string(configBytes),
)
if err != nil {
response.WriteJSON(w, response.Err(-2, "Database error: "+err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) authPeer(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
response.WriteJSON(w, response.Err(401, "Missing Authorization header"))
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
response.WriteJSON(w, response.Err(401, "Invalid Authorization format"))
return
}
token := parts[1]
share, err := h.repo.GetPeerShareByToken(token)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if share == nil {
response.WriteJSON(w, response.Err(401, "Invalid token"))
return
}
if share.IsActive == 0 {
response.WriteJSON(w, response.Err(403, "Share is disabled"))
return
}
if share.ExpiryTime > 0 && share.ExpiryTime < time.Now().UnixMilli() {
response.WriteJSON(w, response.Err(403, "Share expired"))
return
}
if share.AllowedDomains != "" {
clientDomain := r.Header.Get("X-Panel-Domain")
if clientDomain == "" {
response.WriteJSON(w, response.Err(403, "Domain verification required"))
return
}
allowed := false
domains := strings.Split(share.AllowedDomains, ",")
for _, d := range domains {
if strings.TrimSpace(d) == clientDomain {
allowed = true
break
}
}
if !allowed {
response.WriteJSON(w, response.Err(403, "Domain not allowed"))
return
}
}
next(w, r)
}
}
func (h *Handler) federationConnect(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 nodeName string
var serverIP string
var status int
err = h.repo.DB().QueryRow("SELECT name, server_ip, status FROM node WHERE id = ?", share.NodeID).Scan(&nodeName, &serverIP, &status)
if err != nil {
response.WriteJSON(w, response.Err(-2, "Node not found"))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{
"shareId": share.ID,
"shareName": share.Name,
"nodeId": share.NodeID,
"nodeName": nodeName,
"serverIp": serverIP,
"status": status,
"maxBandwidth": share.MaxBandwidth,
"expiryTime": share.ExpiryTime,
"portRangeStart": share.PortRangeStart,
"portRangeEnd": share.PortRangeEnd,
}))
}
func (h *Handler) federationTunnelCreate(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 federationTunnelRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
return
}
if req.RemotePort < share.PortRangeStart || req.RemotePort > share.PortRangeEnd {
response.WriteJSON(w, response.Err(403, "Port out of range"))
return
}
tunnelType := 1
if strings.ToLower(req.Protocol) == "udp" {
tunnelType = 2
}
tx, err := h.repo.DB().Begin()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
defer tx.Rollback()
now := time.Now().UnixMilli()
res, err := tx.Exec(`INSERT INTO tunnel (name, type, protocol, flow, created_time, updated_time, status, in_ip) VALUES (?, ?, ?, 0, ?, ?, 1, ?)`,
fmt.Sprintf("Share-%d-Port-%d", share.ID, req.RemotePort),
tunnelType,
req.Protocol,
now,
now,
"",
)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
tunnelID, _ := res.LastInsertId()
_, err = tx.Exec(`INSERT INTO chain_tunnel (tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES (?, 1, ?, ?, 'fifo', 0, ?)`,
tunnelID,
share.NodeID,
req.RemotePort,
req.Protocol,
)
if 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
}
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
response.WriteJSON(w, response.OK(map[string]interface{}{
"tunnelId": tunnelID,
}))
}
func extractBearerToken(r *http.Request) string {
authHeader := r.Header.Get("Authorization")
parts := strings.Split(authHeader, " ")
if len(parts) == 2 && parts[0] == "Bearer" {
return parts[1]
}
return ""
}
@@ -143,6 +143,12 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/group/permission/assign", h.groupPermissionAssign)
mux.HandleFunc("/api/v1/group/permission/remove", h.groupPermissionRemove)
mux.HandleFunc("/api/v1/open_api/sub_store", h.openAPISubStore)
mux.HandleFunc("/api/v1/federation/share/list", h.federationShareList)
mux.HandleFunc("/api/v1/federation/share/create", h.federationShareCreate)
mux.HandleFunc("/api/v1/federation/share/delete", h.federationShareDelete)
mux.HandleFunc("/api/v1/federation/connect", h.authPeer(h.federationConnect))
mux.HandleFunc("/api/v1/federation/tunnel/create", h.authPeer(h.federationTunnelCreate))
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
mux.HandleFunc("/flow/test", h.flowTest)
mux.HandleFunc("/flow/config", h.flowConfig)
+51 -2
View File
@@ -15,6 +15,7 @@ import (
"strings"
"time"
"go-backend/internal/http/client"
"go-backend/internal/http/response"
"go-backend/internal/security"
)
@@ -273,8 +274,8 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
now := time.Now().UnixMilli()
inx := nextIndex(db, "node")
_, err := db.Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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,
randomToken(16),
@@ -293,6 +294,10 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
defaultString(asString(req["tcpListenAddr"]), "[::]"),
defaultString(asString(req["udpListenAddr"]), "[::]"),
inx,
asInt(req["isRemote"], 0),
nullableText(asString(req["remoteUrl"])),
nullableText(asString(req["remoteToken"])),
nullableText(asString(req["remoteConfig"])),
)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
@@ -509,6 +514,50 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
inIP = buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes)
}
if len(runtimeState.InNodes) > 0 {
firstNodeID := runtimeState.InNodes[0].NodeID
var isRemote int
var rUrl, rToken sql.NullString
if err := h.repo.DB().QueryRow("SELECT is_remote, remote_url, remote_token FROM node WHERE id = ?", firstNodeID).Scan(&isRemote, &rUrl, &rToken); err == nil && isRemote == 1 {
fc := client.NewFederationClient()
targetProto := "tcp"
targetPort := 0
targetAddr := ""
if typeVal == 1 {
if len(runtimeState.OutNodes) > 0 {
outNode := runtimeState.OutNodes[0]
outNodeRec := runtimeState.Nodes[outNode.NodeID]
targetAddr = processServerAddress(outNodeRec.ServerIP)
if outNode.Port > 0 {
targetAddr = fmt.Sprintf("%s:%d", targetAddr, outNode.Port)
}
}
if len(runtimeState.InNodes) > 0 {
inNodesRaw := asMapSlice(req["inNodeId"])
if len(inNodesRaw) > 0 {
targetPort = asInt(inNodesRaw[0]["port"], 0)
targetProto = defaultString(asString(inNodesRaw[0]["protocol"]), "tcp")
}
}
if targetPort > 0 && targetAddr != "" {
domainCfg, _ := h.repo.GetConfigByName("panel_domain")
localDomain := ""
if domainCfg != nil {
localDomain = domainCfg.Value
}
_, err := fc.CreateTunnel(rUrl.String, rToken.String, localDomain, targetProto, targetPort, targetAddr)
if err != nil {
response.WriteJSON(w, response.ErrDefault("Remote tunnel creation failed: "+err.Error()))
return
}
}
}
}
}
res, err := tx.Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
name, trafficRatio, typeVal, "tls", flow, now, now, status, nullableText(inIP), inx)
if err != nil {
@@ -81,6 +81,10 @@ func shouldSkip(path string) bool {
return true
case path == "/api/v1/user/login":
return true
case path == "/api/v1/federation/connect":
return true
case path == "/api/v1/federation/tunnel/create":
return true
default:
return false
}
@@ -91,6 +95,10 @@ func requiresAdmin(path string) bool {
return true
}
if strings.HasPrefix(path, "/api/v1/federation/share/") {
return true
}
if strings.HasPrefix(path, "/api/v1/node/") {
return true
}
+179 -13
View File
@@ -5,6 +5,7 @@ import (
_ "embed"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"sort"
@@ -94,13 +95,33 @@ type StatisticsFlow struct {
}
type Node struct {
ID int64
Secret string
Version sql.NullString
HTTP int
TLS int
Socks int
Status int
ID int64
Secret string
Version sql.NullString
HTTP int
TLS int
Socks int
Status int
IsRemote int
RemoteURL sql.NullString
RemoteToken sql.NullString
RemoteConfig sql.NullString
}
type PeerShare struct {
ID int64 `json:"id"`
Name string `json:"name"`
NodeID int64 `json:"nodeId"`
Token string `json:"token"`
MaxBandwidth int64 `json:"maxBandwidth"`
ExpiryTime int64 `json:"expiryTime"`
PortRangeStart int `json:"portRangeStart"`
PortRangeEnd int `json:"portRangeEnd"`
CurrentFlow int64 `json:"currentFlow"`
IsActive int `json:"isActive"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime"`
AllowedDomains string `json:"allowedDomains"`
}
func Open(path string) (*Repository, error) {
@@ -123,6 +144,11 @@ func Open(path string) (*Repository, error) {
return nil, err
}
if err := migrateSchema(db); err != nil {
_ = db.Close()
return nil, err
}
return &Repository{db: db}, nil
}
@@ -388,9 +414,25 @@ func (r *Repository) GetNodeBySecret(secret string) (*Node, error) {
return nil, errors.New("repository not initialized")
}
row := r.db.QueryRow(`SELECT id, secret, version, http, tls, socks, status FROM node WHERE secret = ? LIMIT 1`, secret)
row := r.db.QueryRow(`SELECT id, secret, version, http, tls, socks, status, is_remote, remote_url, remote_token, remote_config FROM node WHERE secret = ? LIMIT 1`, secret)
var n Node
if err := row.Scan(&n.ID, &n.Secret, &n.Version, &n.HTTP, &n.TLS, &n.Socks, &n.Status); err != nil {
if err := row.Scan(&n.ID, &n.Secret, &n.Version, &n.HTTP, &n.TLS, &n.Socks, &n.Status, &n.IsRemote, &n.RemoteURL, &n.RemoteToken, &n.RemoteConfig); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &n, nil
}
func (r *Repository) GetNodeByID(id int64) (*Node, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
row := r.db.QueryRow(`SELECT id, secret, version, http, tls, socks, status, is_remote, remote_url, remote_token, remote_config FROM node WHERE id = ? LIMIT 1`, id)
var n Node
if err := row.Scan(&n.ID, &n.Secret, &n.Version, &n.HTTP, &n.TLS, &n.Socks, &n.Status, &n.IsRemote, &n.RemoteURL, &n.RemoteToken, &n.RemoteConfig); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -453,7 +495,7 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
}
rows, err := r.db.Query(`
SELECT id, inx, name, server_ip, server_ip_v4, server_ip_v6, port, tcp_listen_addr, udp_listen_addr, version, http, tls, socks, status
SELECT id, inx, name, server_ip, server_ip_v4, server_ip_v6, port, tcp_listen_addr, udp_listen_addr, version, http, tls, socks, status, is_remote, remote_url, remote_token, remote_config
FROM node
ORDER BY inx ASC, id ASC
`)
@@ -466,10 +508,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
for rows.Next() {
var id, inx int64
var name, serverIP, port string
var serverIPV4, serverIPV6, tcpListen, udpListen, version sql.NullString
var httpVal, tlsVal, socksVal, status int
var serverIPV4, serverIPV6, tcpListen, udpListen, version, remoteURL, remoteToken, remoteConfig sql.NullString
var httpVal, tlsVal, socksVal, status, isRemote int
if err := rows.Scan(&id, &inx, &name, &serverIP, &serverIPV4, &serverIPV6, &port, &tcpListen, &udpListen, &version, &httpVal, &tlsVal, &socksVal, &status); err != nil {
if err := rows.Scan(&id, &inx, &name, &serverIP, &serverIPV4, &serverIPV6, &port, &tcpListen, &udpListen, &version, &httpVal, &tlsVal, &socksVal, &status, &isRemote, &remoteURL, &remoteToken, &remoteConfig); err != nil {
return nil, err
}
@@ -489,6 +531,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
"tls": tlsVal,
"socks": socksVal,
"status": status,
"isRemote": isRemote,
"remoteUrl": nullableString(remoteURL),
"remoteToken": nullableString(remoteToken),
"remoteConfig": nullableString(remoteConfig),
})
}
@@ -1176,6 +1222,126 @@ func bootstrapSchema(db *sql.DB) error {
return nil
}
func migrateSchema(db *sql.DB) error {
if db == nil {
return errors.New("nil db")
}
// Add allowed_domains column if it doesn't exist (for existing tables)
var dummy interface{}
err := db.QueryRow("SELECT allowed_domains FROM peer_share LIMIT 1").Scan(&dummy)
if err != nil {
if strings.Contains(err.Error(), "no such column") {
_, err = db.Exec("ALTER TABLE peer_share ADD COLUMN allowed_domains TEXT DEFAULT ''")
if err != nil {
log.Printf("failed to add column allowed_domains to peer_share: %v", err)
}
}
}
columns := map[string]string{
"is_remote": "INTEGER DEFAULT 0",
"remote_url": "TEXT",
"remote_token": "TEXT",
"remote_config": "TEXT",
}
for col, typ := range columns {
var dummy interface{}
err := db.QueryRow(fmt.Sprintf("SELECT %s FROM node LIMIT 1", col)).Scan(&dummy)
if err != nil {
if strings.Contains(err.Error(), "no such column") {
_, err = db.Exec(fmt.Sprintf("ALTER TABLE node ADD COLUMN %s %s", col, typ))
if err != nil {
log.Printf("failed to add column %s: %v", col, err)
}
}
}
}
return nil
}
func (r *Repository) CreatePeerShare(share *PeerShare) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
_, err := r.db.Exec(`
INSERT INTO peer_share(name, node_id, token, max_bandwidth, expiry_time, port_range_start, port_range_end, current_flow, is_active, created_time, updated_time, allowed_domains)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.Name, share.NodeID, share.Token, share.MaxBandwidth, share.ExpiryTime, share.PortRangeStart, share.PortRangeEnd, share.CurrentFlow, share.IsActive, share.CreatedTime, share.UpdatedTime, share.AllowedDomains)
return err
}
func (r *Repository) UpdatePeerShare(share *PeerShare) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
_, err := r.db.Exec(`
UPDATE peer_share SET name=?, max_bandwidth=?, expiry_time=?, port_range_start=?, port_range_end=?, is_active=?, updated_time=?, allowed_domains=?
WHERE id=?
`, share.Name, share.MaxBandwidth, share.ExpiryTime, share.PortRangeStart, share.PortRangeEnd, share.IsActive, share.UpdatedTime, share.AllowedDomains, share.ID)
return err
}
func (r *Repository) DeletePeerShare(id int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
_, err := r.db.Exec(`DELETE FROM peer_share WHERE id=?`, id)
return err
}
func (r *Repository) GetPeerShare(id int64) (*PeerShare, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
row := r.db.QueryRow(`SELECT id, name, node_id, token, max_bandwidth, expiry_time, port_range_start, port_range_end, current_flow, is_active, created_time, updated_time, allowed_domains FROM peer_share WHERE id = ?`, id)
var s PeerShare
if err := row.Scan(&s.ID, &s.Name, &s.NodeID, &s.Token, &s.MaxBandwidth, &s.ExpiryTime, &s.PortRangeStart, &s.PortRangeEnd, &s.CurrentFlow, &s.IsActive, &s.CreatedTime, &s.UpdatedTime, &s.AllowedDomains); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &s, nil
}
func (r *Repository) GetPeerShareByToken(token string) (*PeerShare, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
row := r.db.QueryRow(`SELECT id, name, node_id, token, max_bandwidth, expiry_time, port_range_start, port_range_end, current_flow, is_active, created_time, updated_time, allowed_domains FROM peer_share WHERE token = ?`, token)
var s PeerShare
if err := row.Scan(&s.ID, &s.Name, &s.NodeID, &s.Token, &s.MaxBandwidth, &s.ExpiryTime, &s.PortRangeStart, &s.PortRangeEnd, &s.CurrentFlow, &s.IsActive, &s.CreatedTime, &s.UpdatedTime, &s.AllowedDomains); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &s, nil
}
func (r *Repository) ListPeerShares() ([]PeerShare, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
rows, err := r.db.Query(`SELECT id, name, node_id, token, max_bandwidth, expiry_time, port_range_start, port_range_end, current_flow, is_active, created_time, updated_time, allowed_domains FROM peer_share ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var shares []PeerShare
for rows.Next() {
var s PeerShare
if err := rows.Scan(&s.ID, &s.Name, &s.NodeID, &s.Token, &s.MaxBandwidth, &s.ExpiryTime, &s.PortRangeStart, &s.PortRangeEnd, &s.CurrentFlow, &s.IsActive, &s.CreatedTime, &s.UpdatedTime, &s.AllowedDomains); err != nil {
return nil, err
}
shares = append(shares, s)
}
return shares, nil
}
var osMkdirAll = func(path string) error {
return os.MkdirAll(path, 0o755)
}
@@ -42,7 +42,11 @@ CREATE TABLE IF NOT EXISTS node (
status INTEGER NOT NULL,
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
inx INTEGER NOT NULL DEFAULT 0
inx INTEGER NOT NULL DEFAULT 0,
is_remote INTEGER DEFAULT 0,
remote_url TEXT,
remote_token TEXT,
remote_config TEXT
);
CREATE TABLE IF NOT EXISTS speed_limit (
@@ -181,3 +185,20 @@ CREATE TABLE IF NOT EXISTS vite_config (
value VARCHAR(200) NOT NULL,
time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS peer_share (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
node_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
max_bandwidth INTEGER DEFAULT 0,
expiry_time INTEGER DEFAULT 0,
port_range_start INTEGER DEFAULT 0,
port_range_end INTEGER DEFAULT 0,
current_flow INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL,
allowed_domains TEXT DEFAULT ''
);
+2 -1
View File
@@ -39,8 +39,10 @@
"@heroui/switch": "^2.2.21",
"@heroui/system": "2.4.19",
"@heroui/table": "^2.2.24",
"@heroui/tabs": "^2.2.27",
"@heroui/theme": "2.4.19",
"@heroui/use-theme": "2.1.10",
"@marsidev/react-turnstile": "^1.1.0",
"@nextui-org/system": "^2.4.6",
"@react-aria/visually-hidden": "3.8.25",
"@react-types/shared": "3.30.0",
@@ -56,7 +58,6 @@
"react-hot-toast": "^2.5.2",
"react-is": "^19.2.4",
"react-router-dom": "6.30.3",
"@marsidev/react-turnstile": "^1.1.0",
"recharts": "^3.1.1",
"sonner": "^2.0.6",
"tailwind-variants": "1.0.0",
+10 -1
View File
@@ -12,6 +12,7 @@ import GroupPage from "@/pages/group";
import ProfilePage from "@/pages/profile";
import LimitPage from "@/pages/limit";
import ConfigPage from "@/pages/config";
import PanelSharingPage from "@/pages/panel-sharing";
import { SettingsPage } from "@/pages/settings";
import AdminLayout from "@/layouts/admin";
import H5Layout from "@/layouts/h5";
@@ -235,12 +236,20 @@ function App() {
/>
<Route
element={
<ProtectedRoute useSimpleLayout={true}>
<ProtectedRoute>
<ConfigPage />
</ProtectedRoute>
}
path="/config"
/>
<Route
element={
<ProtectedRoute>
<PanelSharingPage />
</ProtectedRoute>
}
path="/panel-sharing"
/>
<Route element={<SettingsPage />} path="/settings" />
</Routes>
);
+18
View File
@@ -187,3 +187,21 @@ export const assignGroupPermission = (data: {
}) => Network.post("/group/permission/assign", data);
export const removeGroupPermission = (id: number) =>
Network.post("/group/permission/remove", { id });
// 面板共享 (Federation) 接口
export const getPeerShareList = () => Network.post("/federation/share/list");
export const createPeerShare = (data: {
name: string;
nodeId: number;
maxBandwidth?: number;
expiryTime?: number;
portRangeStart?: number;
portRangeEnd?: number;
allowedDomains?: string;
}) => Network.post("/federation/share/create", data);
export const deletePeerShare = (id: number) =>
Network.post("/federation/share/delete", { id });
export const importRemoteNode = (data: {
remoteUrl: string;
token: string;
}) => Network.post("/federation/node/import", data);
+10
View File
@@ -144,6 +144,16 @@ export default function AdminLayout({
),
adminOnly: true,
},
{
path: "/panel-sharing",
label: "共享",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M15 8a3 3 0 10-2.977-2.63l-4.94 2.47a3 3 0 100 4.319l4.94 2.47a3 3 0 10.895-1.789l-4.94-2.47a3.027 3.027 0 000-.74l4.94-2.47C13.456 7.68 14.19 8 15 8z" />
</svg>
),
adminOnly: true,
},
{
path: "/config",
label: "设置",
+8
View File
@@ -56,6 +56,13 @@ const CONFIG_ITEMS: ConfigItem[] = [
"格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密",
type: "input",
},
{
key: "panel_domain",
label: "面板域名",
placeholder: "请输入面板域名",
description: "当前面板的域名,用于与其他面板进行联邦共享时验证身份",
type: "input",
},
{
key: "app_name",
label: "应用名称",
@@ -99,6 +106,7 @@ const getInitialConfigs = (): Record<string, string> => {
"cloudflare_site_key",
"cloudflare_secret_key",
"ip",
"panel_domain",
];
const initialConfigs: Record<string, string> = {};
+15 -1
View File
@@ -62,7 +62,9 @@ interface Node {
http?: number; // 0 关 1 开
tls?: number; // 0 关 1 开
socks?: number; // 0 关 1 开
status: number; // 1: 在线, 0: 离线
status: number;
isRemote?: number;
remoteUrl?: string;
connectionStatus: "online" | "offline";
systemInfo?: {
cpuUsage: number;
@@ -1133,6 +1135,16 @@ export default function NodePage() {
</h3>
</div>
<div className="flex items-center gap-1.5 ml-2">
{node.isRemote === 1 && (
<Chip
className="text-xs"
color="secondary"
size="sm"
variant="flat"
>
远程
</Chip>
)}
<div
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
{...listeners}
@@ -1341,6 +1353,7 @@ export default function NodePage() {
<Button
className="flex-1 min-h-8"
color="success"
isDisabled={node.isRemote === 1}
isLoading={node.copyLoading}
size="sm"
variant="flat"
@@ -1351,6 +1364,7 @@ export default function NodePage() {
<Button
className="flex-1 min-h-8"
color="primary"
isDisabled={node.isRemote === 1}
size="sm"
variant="flat"
onPress={() => handleEdit(node)}
+324
View File
@@ -0,0 +1,324 @@
import { useState, useEffect } from "react";
import { Button } from "@heroui/button";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Tabs, Tab } from "@heroui/tabs";
import { Input } from "@heroui/input";
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
} from "@heroui/modal";
import { Select, SelectItem } from "@heroui/select";
import { toast } from "react-hot-toast";
import {
getNodeList,
createPeerShare,
getPeerShareList,
deletePeerShare,
importRemoteNode,
} from "@/api";
interface Node {
id: number;
name: string;
}
interface PeerShare {
id: number;
name: string;
token: string;
maxBandwidth: number;
expiryTime: number;
portRangeStart: number;
portRangeEnd: number;
isActive: number;
allowedDomains?: string;
}
export default function PanelSharingPage() {
const [selectedTab, setSelectedTab] = useState("my-shares");
const [shares, setShares] = useState<PeerShare[]>([]);
const [nodes, setNodes] = useState<Node[]>([]);
const [loading, setLoading] = useState(false);
// Modals
const [createShareOpen, setCreateShareOpen] = useState(false);
const [importNodeOpen, setImportNodeOpen] = useState(false);
// Forms
const [shareForm, setShareForm] = useState({
name: "",
nodeId: "",
maxBandwidth: 0,
expiryDays: 30,
portRangeStart: 10000,
portRangeEnd: 20000,
allowedDomains: "",
});
const [importForm, setImportForm] = useState({
remoteUrl: "",
token: "",
});
useEffect(() => {
if (selectedTab === "my-shares") {
loadShares();
loadNodes();
}
}, [selectedTab]);
const loadShares = async () => {
setLoading(true);
try {
const res = await getPeerShareList();
if (res.code === 0) {
setShares(res.data || []);
} else {
toast.error(res.msg || "加载分享列表失败");
}
} finally {
setLoading(false);
}
};
const loadNodes = async () => {
try {
const res = await getNodeList();
if (res.code === 0) {
setNodes(res.data || []);
}
} catch {
// ignore
}
};
const handleCreateShare = async () => {
if (!shareForm.name || !shareForm.nodeId) {
toast.error("请填写必要信息");
return;
}
try {
const expiryTime =
Date.now() + shareForm.expiryDays * 24 * 60 * 60 * 1000;
const res = await createPeerShare({
name: shareForm.name,
nodeId: parseInt(shareForm.nodeId),
maxBandwidth: shareForm.maxBandwidth * 1024 * 1024 * 1024,
expiryTime: shareForm.expiryDays === 0 ? 0 : expiryTime,
portRangeStart: shareForm.portRangeStart,
portRangeEnd: shareForm.portRangeEnd,
allowedDomains: shareForm.allowedDomains,
});
if (res.code === 0) {
toast.success("创建成功");
setCreateShareOpen(false);
loadShares();
} else {
toast.error(res.msg || "创建失败");
}
} catch {
toast.error("网络错误");
}
};
const handleDeleteShare = async (id: number) => {
try {
const res = await deletePeerShare(id);
if (res.code === 0) {
toast.success("删除成功");
loadShares();
} else {
toast.error(res.msg || "删除失败");
}
} catch {
toast.error("网络错误");
}
};
const handleImportNode = async () => {
if (!importForm.remoteUrl || !importForm.token) {
toast.error("请填写完整信息");
return;
}
try {
// Automatically add http/https if missing
let url = importForm.remoteUrl.trim();
if (!url.startsWith("http")) {
url = "http://" + url;
}
const res = await importRemoteNode({
remoteUrl: url,
token: importForm.token.trim(),
});
if (res.code === 0) {
toast.success("导入成功,请前往节点列表查看");
setImportNodeOpen(false);
setImportForm({ remoteUrl: "", token: "" });
} else {
toast.error(res.msg || "导入失败");
}
} catch {
toast.error("网络错误");
}
};
const copyToken = (token: string) => {
navigator.clipboard.writeText(token);
toast.success("Token已复制");
};
return (
<div className="p-4 md:p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">面板共享 (Panel Peering)</h1>
</div>
<Tabs
aria-label="Options"
selectedKey={selectedTab}
onSelectionChange={(k) => setSelectedTab(k as string)}
>
<Tab key="my-shares" title="我分享的 (Provider)">
<Card>
<CardBody>
<div className="mb-4">
<Button color="primary" onPress={() => setCreateShareOpen(true)}>
创建分享
</Button>
</div>
{loading ? (
<div className="text-center py-10 text-gray-500">加载中...</div>
) : shares.length === 0 ? (
<div className="text-center py-10 text-gray-500">暂无分享</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{shares.map((share) => (
<Card key={share.id} className="border border-divider shadow-sm">
<CardHeader className="flex justify-between">
<h3 className="font-bold">{share.name}</h3>
<Button size="sm" color="danger" variant="flat" onPress={() => handleDeleteShare(share.id)}>删除</Button>
</CardHeader>
<CardBody className="text-sm space-y-2">
<p>端口范围: {share.portRangeStart} - {share.portRangeEnd}</p>
{share.allowedDomains && <p>允许域名: {share.allowedDomains}</p>}
<p>过期时间: {share.expiryTime === 0 ? "永久" : new Date(share.expiryTime).toLocaleDateString()}</p>
<div className="flex gap-2">
<Input readOnly size="sm" value={share.token} />
<Button size="sm" onPress={() => copyToken(share.token)}>复制</Button>
</div>
</CardBody>
</Card>
))}
</div>
)}
</CardBody>
</Card>
</Tab>
<Tab key="remote-nodes" title="远程节点 (Consumer)">
<Card>
<CardBody>
<div className="mb-4">
<Button color="secondary" onPress={() => setImportNodeOpen(true)}>
导入远程节点
</Button>
</div>
<div className="text-center py-10 text-gray-500">
<p>已导入的节点将显示在“节点管理”页面,带有“远程”标记。</p>
<p className="mt-2">请使用其创建隧道。</p>
</div>
</CardBody>
</Card>
</Tab>
</Tabs>
{/* Create Share Modal */}
<Modal isOpen={createShareOpen} onClose={() => setCreateShareOpen(false)}>
<ModalContent>
<ModalHeader>创建分享</ModalHeader>
<ModalBody>
<Input
label="名称"
placeholder="备注名称"
value={shareForm.name}
onChange={(e) => setShareForm({ ...shareForm, name: e.target.value })}
/>
<Select
label="选择节点"
placeholder="选择要分享的节点"
selectedKeys={shareForm.nodeId ? [shareForm.nodeId] : []}
onChange={(e) => setShareForm({ ...shareForm, nodeId: e.target.value })}
>
{nodes.map((node) => (
<SelectItem key={node.id} textValue={node.name}>
{node.name}
</SelectItem>
))}
</Select>
<div className="flex gap-4">
<Input
label="起始端口"
type="number"
value={shareForm.portRangeStart.toString()}
onChange={(e) => setShareForm({ ...shareForm, portRangeStart: parseInt(e.target.value) })}
/>
<Input
label="结束端口"
type="number"
value={shareForm.portRangeEnd.toString()}
onChange={(e) => setShareForm({ ...shareForm, portRangeEnd: parseInt(e.target.value) })}
/>
</div>
<Input
label="有效期 (天)"
type="number"
description="0 表示永久"
value={shareForm.expiryDays.toString()}
onChange={(e) => setShareForm({ ...shareForm, expiryDays: parseInt(e.target.value) })}
/>
<Input
label="允许的域名 (可选)"
placeholder="example.com, panel.test.com"
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
value={shareForm.allowedDomains}
onChange={(e) => setShareForm({ ...shareForm, allowedDomains: e.target.value })}
/>
</ModalBody>
<ModalFooter>
<Button onPress={() => setCreateShareOpen(false)}>取消</Button>
<Button color="primary" onPress={handleCreateShare}>创建</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Import Node Modal */}
<Modal isOpen={importNodeOpen} onClose={() => setImportNodeOpen(false)}>
<ModalContent>
<ModalHeader>导入远程节点</ModalHeader>
<ModalBody>
<Input
label="远程面板地址"
placeholder="http://panel.example.com:8088"
value={importForm.remoteUrl}
onChange={(e) => setImportForm({ ...importForm, remoteUrl: e.target.value })}
/>
<Input
label="Token"
placeholder="Bearer Token"
value={importForm.token}
onChange={(e) => setImportForm({ ...importForm, token: e.target.value })}
/>
</ModalBody>
<ModalFooter>
<Button onPress={() => setImportNodeOpen(false)}>取消</Button>
<Button color="secondary" onPress={handleImportNode}>导入</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}
+24
View File
@@ -82,6 +82,30 @@ export default function ProfilePage() {
"bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400",
description: "管理用户限速策略",
},
{
path: "/panel-sharing",
label: "面板共享",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M15 8a3 3 0 10-2.977-2.63l-4.94 2.47a3 3 0 100 4.319l4.94 2.47a3 3 0 10.895-1.789l-4.94-2.47a3.027 3.027 0 000-.74l4.94-2.47C13.456 7.68 14.19 8 15 8z" />
</svg>
),
color:
"bg-indigo-100 dark:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400",
description: "与其他面板进行联邦共享",
},
{
path: "/group",
label: "分组管理",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a3 3 0 100 6 3 3 0 000-6zM4 9a3 3 0 100 6 3 3 0 000-6zm12 0a3 3 0 100 6 3 3 0 000-6M4 16a2 2 0 00-2 2h4a2 2 0 00-2-2zm12 0a2 2 0 00-2 2h4a2 2 0 00-2-2zm-6 0a2 2 0 00-2 2h4a2 2 0 00-2-2z" />
</svg>
),
color:
"bg-green-100 dark:bg-green-500/20 text-green-600 dark:text-green-400",
description: "管理用户和隧道分组",
},
{
path: "/user",
label: "用户管理",