mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Merge pull request #49 from Sagit-chu/opencode/silent-wizard
feat: 共享面板
This commit is contained in:
Executable
BIN
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 := ensurePeerSchema(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,145 @@ func bootstrapSchema(db *sql.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensurePeerSchema(db *sql.DB) error {
|
||||
if db == nil {
|
||||
return errors.New("nil db")
|
||||
}
|
||||
|
||||
_, err := db.Exec(`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 ''
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create peer_share: %w", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: "设置",
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,18 @@ export default function ProfilePage() {
|
||||
"bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-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: "用户管理",
|
||||
|
||||
Reference in New Issue
Block a user