mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-03 17:06:36 +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/assign", h.groupPermissionAssign)
|
||||||
mux.HandleFunc("/api/v1/group/permission/remove", h.groupPermissionRemove)
|
mux.HandleFunc("/api/v1/group/permission/remove", h.groupPermissionRemove)
|
||||||
mux.HandleFunc("/api/v1/open_api/sub_store", h.openAPISubStore)
|
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/test", h.flowTest)
|
||||||
mux.HandleFunc("/flow/config", h.flowConfig)
|
mux.HandleFunc("/flow/config", h.flowConfig)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/http/client"
|
||||||
"go-backend/internal/http/response"
|
"go-backend/internal/http/response"
|
||||||
"go-backend/internal/security"
|
"go-backend/internal/security"
|
||||||
)
|
)
|
||||||
@@ -273,8 +274,8 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
inx := nextIndex(db, "node")
|
inx := nextIndex(db, "node")
|
||||||
_, err := db.Exec(`
|
_, 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)
|
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
name,
|
name,
|
||||||
randomToken(16),
|
randomToken(16),
|
||||||
@@ -293,6 +294,10 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
defaultString(asString(req["tcpListenAddr"]), "[::]"),
|
defaultString(asString(req["tcpListenAddr"]), "[::]"),
|
||||||
defaultString(asString(req["udpListenAddr"]), "[::]"),
|
defaultString(asString(req["udpListenAddr"]), "[::]"),
|
||||||
inx,
|
inx,
|
||||||
|
asInt(req["isRemote"], 0),
|
||||||
|
nullableText(asString(req["remoteUrl"])),
|
||||||
|
nullableText(asString(req["remoteToken"])),
|
||||||
|
nullableText(asString(req["remoteConfig"])),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
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)
|
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
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)
|
name, trafficRatio, typeVal, "tls", flow, now, now, status, nullableText(inIP), inx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ func shouldSkip(path string) bool {
|
|||||||
return true
|
return true
|
||||||
case path == "/api/v1/user/login":
|
case path == "/api/v1/user/login":
|
||||||
return true
|
return true
|
||||||
|
case path == "/api/v1/federation/connect":
|
||||||
|
return true
|
||||||
|
case path == "/api/v1/federation/tunnel/create":
|
||||||
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -91,6 +95,10 @@ func requiresAdmin(path string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(path, "/api/v1/federation/share/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(path, "/api/v1/node/") {
|
if strings.HasPrefix(path, "/api/v1/node/") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
_ "embed"
|
_ "embed"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -94,13 +95,33 @@ type StatisticsFlow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Node struct {
|
type Node struct {
|
||||||
ID int64
|
ID int64
|
||||||
Secret string
|
Secret string
|
||||||
Version sql.NullString
|
Version sql.NullString
|
||||||
HTTP int
|
HTTP int
|
||||||
TLS int
|
TLS int
|
||||||
Socks int
|
Socks int
|
||||||
Status 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) {
|
func Open(path string) (*Repository, error) {
|
||||||
@@ -123,6 +144,11 @@ func Open(path string) (*Repository, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ensurePeerSchema(db); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &Repository{db: db}, nil
|
return &Repository{db: db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,9 +414,25 @@ func (r *Repository) GetNodeBySecret(secret string) (*Node, error) {
|
|||||||
return nil, errors.New("repository not initialized")
|
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
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -453,7 +495,7 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := r.db.Query(`
|
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
|
FROM node
|
||||||
ORDER BY inx ASC, id ASC
|
ORDER BY inx ASC, id ASC
|
||||||
`)
|
`)
|
||||||
@@ -466,10 +508,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id, inx int64
|
var id, inx int64
|
||||||
var name, serverIP, port string
|
var name, serverIP, port string
|
||||||
var serverIPV4, serverIPV6, tcpListen, udpListen, version sql.NullString
|
var serverIPV4, serverIPV6, tcpListen, udpListen, version, remoteURL, remoteToken, remoteConfig sql.NullString
|
||||||
var httpVal, tlsVal, socksVal, status int
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,6 +531,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
|||||||
"tls": tlsVal,
|
"tls": tlsVal,
|
||||||
"socks": socksVal,
|
"socks": socksVal,
|
||||||
"status": status,
|
"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
|
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 {
|
var osMkdirAll = func(path string) error {
|
||||||
return os.MkdirAll(path, 0o755)
|
return os.MkdirAll(path, 0o755)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,8 +39,10 @@
|
|||||||
"@heroui/switch": "^2.2.21",
|
"@heroui/switch": "^2.2.21",
|
||||||
"@heroui/system": "2.4.19",
|
"@heroui/system": "2.4.19",
|
||||||
"@heroui/table": "^2.2.24",
|
"@heroui/table": "^2.2.24",
|
||||||
|
"@heroui/tabs": "^2.2.27",
|
||||||
"@heroui/theme": "2.4.19",
|
"@heroui/theme": "2.4.19",
|
||||||
"@heroui/use-theme": "2.1.10",
|
"@heroui/use-theme": "2.1.10",
|
||||||
|
"@marsidev/react-turnstile": "^1.1.0",
|
||||||
"@nextui-org/system": "^2.4.6",
|
"@nextui-org/system": "^2.4.6",
|
||||||
"@react-aria/visually-hidden": "3.8.25",
|
"@react-aria/visually-hidden": "3.8.25",
|
||||||
"@react-types/shared": "3.30.0",
|
"@react-types/shared": "3.30.0",
|
||||||
@@ -56,7 +58,6 @@
|
|||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-is": "^19.2.4",
|
"react-is": "^19.2.4",
|
||||||
"react-router-dom": "6.30.3",
|
"react-router-dom": "6.30.3",
|
||||||
"@marsidev/react-turnstile": "^1.1.0",
|
|
||||||
"recharts": "^3.1.1",
|
"recharts": "^3.1.1",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-variants": "1.0.0",
|
"tailwind-variants": "1.0.0",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import GroupPage from "@/pages/group";
|
|||||||
import ProfilePage from "@/pages/profile";
|
import ProfilePage from "@/pages/profile";
|
||||||
import LimitPage from "@/pages/limit";
|
import LimitPage from "@/pages/limit";
|
||||||
import ConfigPage from "@/pages/config";
|
import ConfigPage from "@/pages/config";
|
||||||
|
import PanelSharingPage from "@/pages/panel-sharing";
|
||||||
import { SettingsPage } from "@/pages/settings";
|
import { SettingsPage } from "@/pages/settings";
|
||||||
import AdminLayout from "@/layouts/admin";
|
import AdminLayout from "@/layouts/admin";
|
||||||
import H5Layout from "@/layouts/h5";
|
import H5Layout from "@/layouts/h5";
|
||||||
@@ -235,12 +236,20 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute useSimpleLayout={true}>
|
<ProtectedRoute>
|
||||||
<ConfigPage />
|
<ConfigPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
path="/config"
|
path="/config"
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PanelSharingPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
path="/panel-sharing"
|
||||||
|
/>
|
||||||
<Route element={<SettingsPage />} path="/settings" />
|
<Route element={<SettingsPage />} path="/settings" />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -187,3 +187,21 @@ export const assignGroupPermission = (data: {
|
|||||||
}) => Network.post("/group/permission/assign", data);
|
}) => Network.post("/group/permission/assign", data);
|
||||||
export const removeGroupPermission = (id: number) =>
|
export const removeGroupPermission = (id: number) =>
|
||||||
Network.post("/group/permission/remove", { id });
|
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,
|
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",
|
path: "/config",
|
||||||
label: "设置",
|
label: "设置",
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ const CONFIG_ITEMS: ConfigItem[] = [
|
|||||||
"格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密",
|
"格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密",
|
||||||
type: "input",
|
type: "input",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "panel_domain",
|
||||||
|
label: "面板域名",
|
||||||
|
placeholder: "请输入面板域名",
|
||||||
|
description: "当前面板的域名,用于与其他面板进行联邦共享时验证身份",
|
||||||
|
type: "input",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "app_name",
|
key: "app_name",
|
||||||
label: "应用名称",
|
label: "应用名称",
|
||||||
@@ -99,6 +106,7 @@ const getInitialConfigs = (): Record<string, string> => {
|
|||||||
"cloudflare_site_key",
|
"cloudflare_site_key",
|
||||||
"cloudflare_secret_key",
|
"cloudflare_secret_key",
|
||||||
"ip",
|
"ip",
|
||||||
|
"panel_domain",
|
||||||
];
|
];
|
||||||
const initialConfigs: Record<string, string> = {};
|
const initialConfigs: Record<string, string> = {};
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ interface Node {
|
|||||||
http?: number; // 0 关 1 开
|
http?: number; // 0 关 1 开
|
||||||
tls?: number; // 0 关 1 开
|
tls?: number; // 0 关 1 开
|
||||||
socks?: number; // 0 关 1 开
|
socks?: number; // 0 关 1 开
|
||||||
status: number; // 1: 在线, 0: 离线
|
status: number;
|
||||||
|
isRemote?: number;
|
||||||
|
remoteUrl?: string;
|
||||||
connectionStatus: "online" | "offline";
|
connectionStatus: "online" | "offline";
|
||||||
systemInfo?: {
|
systemInfo?: {
|
||||||
cpuUsage: number;
|
cpuUsage: number;
|
||||||
@@ -1133,6 +1135,16 @@ export default function NodePage() {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 ml-2">
|
<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
|
<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"
|
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}
|
{...listeners}
|
||||||
@@ -1341,6 +1353,7 @@ export default function NodePage() {
|
|||||||
<Button
|
<Button
|
||||||
className="flex-1 min-h-8"
|
className="flex-1 min-h-8"
|
||||||
color="success"
|
color="success"
|
||||||
|
isDisabled={node.isRemote === 1}
|
||||||
isLoading={node.copyLoading}
|
isLoading={node.copyLoading}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -1351,6 +1364,7 @@ export default function NodePage() {
|
|||||||
<Button
|
<Button
|
||||||
className="flex-1 min-h-8"
|
className="flex-1 min-h-8"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
isDisabled={node.isRemote === 1}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => handleEdit(node)}
|
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",
|
"bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400",
|
||||||
description: "管理用户限速策略",
|
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",
|
path: "/user",
|
||||||
label: "用户管理",
|
label: "用户管理",
|
||||||
|
|||||||
Reference in New Issue
Block a user