mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-04 17:16:37 +08:00
Merge pull request #75 from Sagit-chu/opencode/shiny-squid
feat(federation): add share flow reset and remote usage visibility
This commit is contained in:
@@ -21,6 +21,7 @@ type RemoteNodeInfo struct {
|
|||||||
ServerIP string `json:"serverIp"`
|
ServerIP string `json:"serverIp"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
MaxBandwidth int64 `json:"maxBandwidth"`
|
MaxBandwidth int64 `json:"maxBandwidth"`
|
||||||
|
CurrentFlow int64 `json:"currentFlow"`
|
||||||
ExpiryTime int64 `json:"expiryTime"`
|
ExpiryTime int64 `json:"expiryTime"`
|
||||||
PortRangeStart int `json:"portRangeStart"`
|
PortRangeStart int `json:"portRangeStart"`
|
||||||
PortRangeEnd int `json:"portRangeEnd"`
|
PortRangeEnd int `json:"portRangeEnd"`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -35,6 +36,10 @@ type deletePeerShareRequest struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type resetPeerShareFlowRequest struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
type nodeImportRequest struct {
|
type nodeImportRequest struct {
|
||||||
RemoteURL string `json:"remoteUrl"`
|
RemoteURL string `json:"remoteUrl"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
@@ -74,6 +79,49 @@ type federationRuntimeDiagnoseRequest struct {
|
|||||||
Timeout int `json:"timeout"`
|
Timeout int `json:"timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type peerShareUsedPort struct {
|
||||||
|
RuntimeID int64 `json:"runtimeId"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
ResourceKey string `json:"resourceKey"`
|
||||||
|
Applied int `json:"applied"`
|
||||||
|
UpdatedTime int64 `json:"updatedTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type peerShareListItem struct {
|
||||||
|
sqlite.PeerShare
|
||||||
|
UsedPorts []int `json:"usedPorts"`
|
||||||
|
UsedPortDetails []peerShareUsedPort `json:"usedPortDetails"`
|
||||||
|
ActiveRuntimeNum int `json:"activeRuntimeNum"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type remoteUsageBindingItem struct {
|
||||||
|
BindingID int64 `json:"bindingId"`
|
||||||
|
TunnelID int64 `json:"tunnelId"`
|
||||||
|
TunnelName string `json:"tunnelName"`
|
||||||
|
ChainType int `json:"chainType"`
|
||||||
|
HopInx int `json:"hopInx"`
|
||||||
|
AllocatedPort int `json:"allocatedPort"`
|
||||||
|
ResourceKey string `json:"resourceKey"`
|
||||||
|
RemoteBindingID string `json:"remoteBindingId"`
|
||||||
|
UpdatedTime int64 `json:"updatedTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type remoteUsageNodeItem struct {
|
||||||
|
NodeID int64 `json:"nodeId"`
|
||||||
|
NodeName string `json:"nodeName"`
|
||||||
|
RemoteURL string `json:"remoteUrl"`
|
||||||
|
ShareID int64 `json:"shareId"`
|
||||||
|
PortRangeStart int `json:"portRangeStart"`
|
||||||
|
PortRangeEnd int `json:"portRangeEnd"`
|
||||||
|
MaxBandwidth int64 `json:"maxBandwidth"`
|
||||||
|
CurrentFlow int64 `json:"currentFlow"`
|
||||||
|
UsedPorts []int `json:"usedPorts"`
|
||||||
|
Bindings []remoteUsageBindingItem `json:"bindings"`
|
||||||
|
ActiveBindingNum int `json:"activeBindingNum"`
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) federationShareList(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) federationShareList(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||||
@@ -85,7 +133,55 @@ func (h *Handler) federationShareList(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.WriteJSON(w, response.OK(shares))
|
|
||||||
|
items := make([]peerShareListItem, 0, len(shares))
|
||||||
|
for i := range shares {
|
||||||
|
share := shares[i]
|
||||||
|
runtimes, err := h.repo.ListActivePeerShareRuntimesByShareID(share.ID)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usedSet := make(map[int]struct{}, len(runtimes))
|
||||||
|
details := make([]peerShareUsedPort, 0, len(runtimes))
|
||||||
|
for _, runtime := range runtimes {
|
||||||
|
if runtime.Port > 0 {
|
||||||
|
usedSet[runtime.Port] = struct{}{}
|
||||||
|
}
|
||||||
|
details = append(details, peerShareUsedPort{
|
||||||
|
RuntimeID: runtime.ID,
|
||||||
|
Port: runtime.Port,
|
||||||
|
Role: runtime.Role,
|
||||||
|
Protocol: runtime.Protocol,
|
||||||
|
ResourceKey: runtime.ResourceKey,
|
||||||
|
Applied: runtime.Applied,
|
||||||
|
UpdatedTime: runtime.UpdatedTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
usedPorts := make([]int, 0, len(usedSet))
|
||||||
|
for port := range usedSet {
|
||||||
|
usedPorts = append(usedPorts, port)
|
||||||
|
}
|
||||||
|
sort.Ints(usedPorts)
|
||||||
|
|
||||||
|
sort.Slice(details, func(i, j int) bool {
|
||||||
|
if details[i].Port == details[j].Port {
|
||||||
|
return details[i].RuntimeID < details[j].RuntimeID
|
||||||
|
}
|
||||||
|
return details[i].Port < details[j].Port
|
||||||
|
})
|
||||||
|
|
||||||
|
items = append(items, peerShareListItem{
|
||||||
|
PeerShare: share,
|
||||||
|
UsedPorts: usedPorts,
|
||||||
|
UsedPortDetails: details,
|
||||||
|
ActiveRuntimeNum: len(details),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
response.WriteJSON(w, response.OK(items))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) federationShareCreate(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) federationShareCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -191,6 +287,153 @@ func (h *Handler) federationShareDelete(w http.ResponseWriter, r *http.Request)
|
|||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) federationShareResetFlow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req resetPeerShareFlowRequest
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ID <= 0 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Share ID is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
share, err := h.repo.GetPeerShare(req.ID)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if share == nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Share not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.repo.ResetPeerShareCurrentFlow(req.ID, time.Now().UnixMilli()); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.repo.DB().Query(`
|
||||||
|
SELECT id, name, remote_url, remote_config
|
||||||
|
FROM node
|
||||||
|
WHERE is_remote = 1
|
||||||
|
ORDER BY id DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]remoteUsageNodeItem, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
nodeID int64
|
||||||
|
nodeName string
|
||||||
|
remoteURL sql.NullString
|
||||||
|
remoteConfig sql.NullString
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&nodeID, &nodeName, &remoteURL, &remoteConfig); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shareID, maxBandwidth, currentFlow, portRangeStart, portRangeEnd := parseRemoteShareUsageConfig(remoteConfig.String)
|
||||||
|
|
||||||
|
bindingRows, err := h.repo.DB().Query(`
|
||||||
|
SELECT fb.id, fb.tunnel_id, COALESCE(t.name, ''), fb.chain_type, fb.hop_inx, fb.allocated_port, fb.resource_key, fb.remote_binding_id, fb.updated_time
|
||||||
|
FROM federation_tunnel_binding fb
|
||||||
|
LEFT JOIN tunnel t ON t.id = fb.tunnel_id
|
||||||
|
WHERE fb.node_id = ? AND fb.status = 1
|
||||||
|
ORDER BY fb.allocated_port ASC, fb.id ASC
|
||||||
|
`, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usedSet := make(map[int]struct{})
|
||||||
|
bindings := make([]remoteUsageBindingItem, 0)
|
||||||
|
for bindingRows.Next() {
|
||||||
|
var item remoteUsageBindingItem
|
||||||
|
if err := bindingRows.Scan(&item.BindingID, &item.TunnelID, &item.TunnelName, &item.ChainType, &item.HopInx, &item.AllocatedPort, &item.ResourceKey, &item.RemoteBindingID, &item.UpdatedTime); err != nil {
|
||||||
|
_ = bindingRows.Close()
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bindings = append(bindings, item)
|
||||||
|
if item.AllocatedPort > 0 {
|
||||||
|
usedSet[item.AllocatedPort] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := bindingRows.Err(); err != nil {
|
||||||
|
_ = bindingRows.Close()
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = bindingRows.Close()
|
||||||
|
|
||||||
|
usedPorts := make([]int, 0, len(usedSet))
|
||||||
|
for port := range usedSet {
|
||||||
|
usedPorts = append(usedPorts, port)
|
||||||
|
}
|
||||||
|
sort.Ints(usedPorts)
|
||||||
|
|
||||||
|
items = append(items, remoteUsageNodeItem{
|
||||||
|
NodeID: nodeID,
|
||||||
|
NodeName: nodeName,
|
||||||
|
RemoteURL: strings.TrimSpace(remoteURL.String),
|
||||||
|
ShareID: shareID,
|
||||||
|
PortRangeStart: portRangeStart,
|
||||||
|
PortRangeEnd: portRangeEnd,
|
||||||
|
MaxBandwidth: maxBandwidth,
|
||||||
|
CurrentFlow: currentFlow,
|
||||||
|
UsedPorts: usedPorts,
|
||||||
|
Bindings: bindings,
|
||||||
|
ActiveBindingNum: len(bindings),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.WriteJSON(w, response.OK(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRemoteShareUsageConfig(raw string) (int64, int64, int64, int, int) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return 0, 0, 0, 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||||
|
return 0, 0, 0, 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
shareID := asInt64(cfg["shareId"], 0)
|
||||||
|
maxBandwidth := asInt64(cfg["maxBandwidth"], 0)
|
||||||
|
currentFlow := asInt64(cfg["currentFlow"], 0)
|
||||||
|
portRangeStart := int(asInt64(cfg["portRangeStart"], 0))
|
||||||
|
portRangeEnd := int(asInt64(cfg["portRangeEnd"], 0))
|
||||||
|
return shareID, maxBandwidth, currentFlow, portRangeStart, portRangeEnd
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||||
@@ -225,6 +468,7 @@ func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
|
|||||||
configData := map[string]interface{}{
|
configData := map[string]interface{}{
|
||||||
"shareId": info.ShareID,
|
"shareId": info.ShareID,
|
||||||
"maxBandwidth": info.MaxBandwidth,
|
"maxBandwidth": info.MaxBandwidth,
|
||||||
|
"currentFlow": info.CurrentFlow,
|
||||||
"expiryTime": info.ExpiryTime,
|
"expiryTime": info.ExpiryTime,
|
||||||
"portRangeStart": info.PortRangeStart,
|
"portRangeStart": info.PortRangeStart,
|
||||||
"portRangeEnd": info.PortRangeEnd,
|
"portRangeEnd": info.PortRangeEnd,
|
||||||
@@ -370,6 +614,7 @@ func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
|
|||||||
"serverIp": serverIP,
|
"serverIp": serverIP,
|
||||||
"status": status,
|
"status": status,
|
||||||
"maxBandwidth": share.MaxBandwidth,
|
"maxBandwidth": share.MaxBandwidth,
|
||||||
|
"currentFlow": share.CurrentFlow,
|
||||||
"expiryTime": share.ExpiryTime,
|
"expiryTime": share.ExpiryTime,
|
||||||
"portRangeStart": share.PortRangeStart,
|
"portRangeStart": share.PortRangeStart,
|
||||||
"portRangeEnd": share.PortRangeEnd,
|
"portRangeEnd": share.PortRangeEnd,
|
||||||
@@ -388,6 +633,10 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
|||||||
response.WriteJSON(w, response.Err(401, "Unauthorized"))
|
response.WriteJSON(w, response.Err(401, "Unauthorized"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if isPeerShareFlowExceeded(share) {
|
||||||
|
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req federationTunnelRequest
|
var req federationTunnelRequest
|
||||||
if err := decodeJSON(r.Body, &req); err != nil {
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
@@ -488,6 +737,10 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
|
|||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if isPeerShareFlowExceeded(share) {
|
||||||
|
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
allocatedPort, err := h.pickPeerSharePort(share, req.RequestedPort)
|
allocatedPort, err := h.pickPeerSharePort(share, req.RequestedPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -597,6 +850,10 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
|||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if isPeerShareFlowExceeded(share) {
|
||||||
|
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
node, err := h.getNodeRecord(share.NodeID)
|
node, err := h.getNodeRecord(share.NodeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -885,6 +1142,16 @@ func extractBearerToken(r *http.Request) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isPeerShareFlowExceeded(share *sqlite.PeerShare) bool {
|
||||||
|
if share == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if share.MaxBandwidth <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return share.CurrentFlow >= share.MaxBandwidth
|
||||||
|
}
|
||||||
|
|
||||||
func normalizePeerShareAllowedIPs(raw string) (string, error) {
|
func normalizePeerShareAllowedIPs(raw string) (string, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/http/response"
|
||||||
"go-backend/internal/store/sqlite"
|
"go-backend/internal/store/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -146,3 +151,60 @@ func TestPrepareTunnelCreateStateRemoteAutoPortDefersToFederation(t *testing.T)
|
|||||||
t.Fatalf("expected remote out port to remain 0 before federation reserve, got %d", state.OutNodes[0].Port)
|
t.Fatalf("expected remote out port to remain 0 before federation reserve, got %d", state.OutNodes[0].Port)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
|
||||||
|
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repo: %v", err)
|
||||||
|
}
|
||||||
|
defer repo.Close()
|
||||||
|
|
||||||
|
h := &Handler{repo: repo}
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||||
|
Name: "limited-share",
|
||||||
|
NodeID: 1,
|
||||||
|
Token: "limited-token",
|
||||||
|
MaxBandwidth: 2048,
|
||||||
|
CurrentFlow: 2048,
|
||||||
|
PortRangeStart: 30000,
|
||||||
|
PortRangeEnd: 30010,
|
||||||
|
IsActive: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create share: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(map[string]interface{}{
|
||||||
|
"resourceKey": "tunnel:1:node:1:type:3:hop:0",
|
||||||
|
"protocol": "tls",
|
||||||
|
"requestedPort": 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/reserve-port", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer limited-token")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.federationRuntimeReservePort(res, req)
|
||||||
|
|
||||||
|
if res.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload response.R
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Code != 403 {
|
||||||
|
t.Fatalf("expected response code 403, got %d (%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
if payload.Msg != "Share traffic limit exceeded" {
|
||||||
|
t.Fatalf("unexpected response message: %q", payload.Msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -144,6 +144,251 @@ func TestFederationShareCreateRejectsInvalidAllowedIPs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
|
||||||
|
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = repo.Close() })
|
||||||
|
|
||||||
|
h := New(repo, "test-jwt-secret")
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||||
|
Name: "provider-share",
|
||||||
|
NodeID: 9,
|
||||||
|
Token: "share-list-token",
|
||||||
|
MaxBandwidth: 1024,
|
||||||
|
CurrentFlow: 512,
|
||||||
|
PortRangeStart: 22000,
|
||||||
|
PortRangeEnd: 22010,
|
||||||
|
IsActive: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create peer share: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
share, err := repo.GetPeerShareByToken("share-list-token")
|
||||||
|
if err != nil || share == nil {
|
||||||
|
t.Fatalf("load peer share: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.DB().Exec(`
|
||||||
|
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||||
|
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||||
|
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
share.ID, share.NodeID, "r-1", "rk-1", "b-1", "middle", "fed_chain_1", "fed_svc_1", "tls", "round", 22001, "", 1, 1, now, now,
|
||||||
|
share.ID, share.NodeID, "r-2", "rk-2", "b-2", "exit", "", "fed_svc_2", "tls", "round", 22002, "", 1, 1, now, now,
|
||||||
|
share.ID, share.NodeID, "r-3", "rk-3", "", "", "", "", "tls", "round", 22003, "", 0, 0, now, now,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("insert peer_share_runtime rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/list", nil)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
h.federationShareList(res, req)
|
||||||
|
|
||||||
|
if res.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload response.R
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, ok := payload.Data.([]interface{})
|
||||||
|
if !ok || len(rows) == 0 {
|
||||||
|
t.Fatalf("expected non-empty share list, got %T", payload.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
first, ok := rows[0].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected share row object, got %T", rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if int(first["activeRuntimeNum"].(float64)) != 2 {
|
||||||
|
t.Fatalf("expected activeRuntimeNum=2, got %v", first["activeRuntimeNum"])
|
||||||
|
}
|
||||||
|
|
||||||
|
usedPortsRaw, ok := first["usedPorts"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected usedPorts array, got %T", first["usedPorts"])
|
||||||
|
}
|
||||||
|
if len(usedPortsRaw) != 2 {
|
||||||
|
t.Fatalf("expected 2 used ports, got %d", len(usedPortsRaw))
|
||||||
|
}
|
||||||
|
if int(usedPortsRaw[0].(float64)) != 22001 || int(usedPortsRaw[1].(float64)) != 22002 {
|
||||||
|
t.Fatalf("unexpected used ports payload: %v", usedPortsRaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
detailsRaw, ok := first["usedPortDetails"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected usedPortDetails array, got %T", first["usedPortDetails"])
|
||||||
|
}
|
||||||
|
if len(detailsRaw) != 2 {
|
||||||
|
t.Fatalf("expected 2 usedPortDetails rows, got %d", len(detailsRaw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFederationShareResetFlow(t *testing.T) {
|
||||||
|
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = repo.Close() })
|
||||||
|
|
||||||
|
h := New(repo, "test-jwt-secret")
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||||
|
Name: "reset-flow-share",
|
||||||
|
NodeID: 11,
|
||||||
|
Token: "reset-flow-token",
|
||||||
|
MaxBandwidth: 4096,
|
||||||
|
CurrentFlow: 2048,
|
||||||
|
PortRangeStart: 23000,
|
||||||
|
PortRangeEnd: 23010,
|
||||||
|
IsActive: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create peer share: %v", err)
|
||||||
|
}
|
||||||
|
share, err := repo.GetPeerShareByToken("reset-flow-token")
|
||||||
|
if err != nil || share == nil {
|
||||||
|
t.Fatalf("load peer share: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(resetPeerShareFlowRequest{ID: share.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal request: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/reset-flow", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.federationShareResetFlow(res, req)
|
||||||
|
|
||||||
|
if res.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||||
|
}
|
||||||
|
var payload response.R
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := repo.GetPeerShare(share.ID)
|
||||||
|
if err != nil || updated == nil {
|
||||||
|
t.Fatalf("reload peer share: %v", err)
|
||||||
|
}
|
||||||
|
if updated.CurrentFlow != 0 {
|
||||||
|
t.Fatalf("expected current flow reset to 0, got %d", updated.CurrentFlow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFederationRemoteUsageList(t *testing.T) {
|
||||||
|
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = repo.Close() })
|
||||||
|
|
||||||
|
h := New(repo, "test-jwt-secret")
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
resNode, err := repo.DB().Exec(`
|
||||||
|
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert remote node: %v", err)
|
||||||
|
}
|
||||||
|
nodeID, err := resNode.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("remote node id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resTunnelA, err := repo.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-a", 2, "tls", 1, now, now, 1, "", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert tunnel a: %v", err)
|
||||||
|
}
|
||||||
|
tunnelAID, _ := resTunnelA.LastInsertId()
|
||||||
|
|
||||||
|
resTunnelB, err := repo.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-b", 2, "tls", 1, now, now, 1, "", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert tunnel b: %v", err)
|
||||||
|
}
|
||||||
|
tunnelBID, _ := resTunnelB.LastInsertId()
|
||||||
|
|
||||||
|
if _, err := repo.DB().Exec(`
|
||||||
|
INSERT INTO federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx, remote_url, resource_key, remote_binding_id, allocated_port, status, created_time, updated_time)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||||
|
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
tunnelAID, nodeID, 2, 1, "http://peer.example", "rk-a", "rb-a", 31001, 1, now, now,
|
||||||
|
tunnelBID, nodeID, 3, 0, "http://peer.example", "rk-b", "rb-b", 31002, 1, now, now,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("insert federation bindings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/remote-usage/list", nil)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
h.federationRemoteUsageList(res, req)
|
||||||
|
|
||||||
|
if res.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload response.R
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, ok := payload.Data.([]interface{})
|
||||||
|
if !ok || len(rows) == 0 {
|
||||||
|
t.Fatalf("expected non-empty usage list, got %T", payload.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
first, ok := rows[0].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected first usage row map, got %T", rows[0])
|
||||||
|
}
|
||||||
|
if int64(first["shareId"].(float64)) != 88 {
|
||||||
|
t.Fatalf("expected shareId=88, got %v", first["shareId"])
|
||||||
|
}
|
||||||
|
|
||||||
|
usedPortsRaw, ok := first["usedPorts"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected usedPorts array, got %T", first["usedPorts"])
|
||||||
|
}
|
||||||
|
if len(usedPortsRaw) != 2 {
|
||||||
|
t.Fatalf("expected 2 used ports, got %d", len(usedPortsRaw))
|
||||||
|
}
|
||||||
|
if int(usedPortsRaw[0].(float64)) != 31001 || int(usedPortsRaw[1].(float64)) != 31002 {
|
||||||
|
t.Fatalf("unexpected used ports payload: %v", usedPortsRaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
bindingsRaw, ok := first["bindings"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected bindings array, got %T", first["bindings"])
|
||||||
|
}
|
||||||
|
if len(bindingsRaw) != 2 {
|
||||||
|
t.Fatalf("expected 2 binding rows, got %d", len(bindingsRaw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthPeerAllowedIPs(t *testing.T) {
|
func TestAuthPeerAllowedIPs(t *testing.T) {
|
||||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -38,16 +38,21 @@ func (h *Handler) processFlowItem(item flowItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
|
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
|
||||||
if !ok {
|
if ok {
|
||||||
|
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||||
|
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||||
|
|
||||||
|
if userTunnelID > 0 {
|
||||||
|
h.enforceFlowPolicies(userID, userTunnelID)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
runtimeID, ok := parsePeerShareRuntimeServiceID(serviceName)
|
||||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
if !ok {
|
||||||
|
return
|
||||||
if userTunnelID > 0 {
|
|
||||||
h.enforceFlowPolicies(userID, userTunnelID)
|
|
||||||
}
|
}
|
||||||
|
h.processPeerShareFlow(runtimeID, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseFlowServiceIDs(serviceName string) (int64, int64, int64, bool) {
|
func parseFlowServiceIDs(serviceName string) (int64, int64, int64, bool) {
|
||||||
@@ -66,6 +71,72 @@ func parseFlowServiceIDs(serviceName string) (int64, int64, int64, bool) {
|
|||||||
return forwardID, userID, userTunnelID, true
|
return forwardID, userID, userTunnelID, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsePeerShareRuntimeServiceID(serviceName string) (int64, bool) {
|
||||||
|
const prefix = "fed_svc_"
|
||||||
|
if !strings.HasPrefix(serviceName, prefix) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
raw := strings.TrimPrefix(serviceName, prefix)
|
||||||
|
if raw == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(raw, "_", 2)
|
||||||
|
runtimeID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err != nil || runtimeID <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return runtimeID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
|
||||||
|
if h == nil || h.repo == nil || runtimeID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runtime, err := h.repo.GetPeerShareRuntimeByID(runtimeID)
|
||||||
|
if err != nil || runtime == nil || runtime.ShareID <= 0 || runtime.Status != 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
delta := item.D + item.U
|
||||||
|
if delta <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
|
||||||
|
|
||||||
|
share, err := h.repo.GetPeerShare(runtime.ShareID)
|
||||||
|
if err != nil || share == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isPeerShareFlowExceeded(share) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.enforcePeerShareFlowLimit(share.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
|
||||||
|
if h == nil || h.repo == nil || shareID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runtimes, err := h.repo.ListActivePeerShareRuntimesByShareID(shareID)
|
||||||
|
if err != nil || len(runtimes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
for _, runtime := range runtimes {
|
||||||
|
if h.wsServer != nil && runtime.Applied == 1 {
|
||||||
|
if strings.TrimSpace(runtime.ServiceName) != "" {
|
||||||
|
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteService", map[string]interface{}{"services": []string{runtime.ServiceName}}, false, true)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(runtime.Role) == "middle" && strings.TrimSpace(runtime.ChainName) != "" {
|
||||||
|
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = h.repo.MarkPeerShareRuntimeReleased(runtime.ID, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) scaleFlowByTunnel(forwardID int64, inFlow int64, outFlow int64) (int64, int64) {
|
func (h *Handler) scaleFlowByTunnel(forwardID int64, inFlow int64, outFlow int64) (int64, int64) {
|
||||||
forward, err := h.getForwardRecord(forwardID)
|
forward, err := h.getForwardRecord(forwardID)
|
||||||
if err != nil || forward == nil {
|
if err != nil || forward == nil {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/store/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||||
|
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repo: %v", err)
|
||||||
|
}
|
||||||
|
defer repo.Close()
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||||
|
Name: "flow-share",
|
||||||
|
NodeID: 1,
|
||||||
|
Token: "flow-share-token",
|
||||||
|
MaxBandwidth: 3000,
|
||||||
|
CurrentFlow: 1000,
|
||||||
|
PortRangeStart: 32000,
|
||||||
|
PortRangeEnd: 32010,
|
||||||
|
IsActive: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create peer share: %v", err)
|
||||||
|
}
|
||||||
|
share, err := repo.GetPeerShareByToken("flow-share-token")
|
||||||
|
if err != nil || share == nil {
|
||||||
|
t.Fatalf("load peer share: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.DB().Exec(`
|
||||||
|
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, 17, share.ID, share.NodeID, "res-17", "rk-17", "17", "exit", "", "fed_svc_17", "tls", "round", 32001, "", 1, 1, now, now); err != nil {
|
||||||
|
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handler{repo: repo}
|
||||||
|
h.processFlowItem(flowItem{N: "fed_svc_17", U: 1200, D: 900})
|
||||||
|
|
||||||
|
updatedShare, err := repo.GetPeerShare(share.ID)
|
||||||
|
if err != nil || updatedShare == nil {
|
||||||
|
t.Fatalf("reload share: %v", err)
|
||||||
|
}
|
||||||
|
if updatedShare.CurrentFlow != 3100 {
|
||||||
|
t.Fatalf("expected current_flow=3100, got %d", updatedShare.CurrentFlow)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime, err := repo.GetPeerShareRuntimeByID(17)
|
||||||
|
if err != nil || runtime == nil {
|
||||||
|
t.Fatalf("reload runtime: %v", err)
|
||||||
|
}
|
||||||
|
if runtime.Status != 0 {
|
||||||
|
t.Fatalf("expected runtime status=0 after limit enforcement, got %d", runtime.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,6 +156,8 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/federation/share/list", h.federationShareList)
|
mux.HandleFunc("/api/v1/federation/share/list", h.federationShareList)
|
||||||
mux.HandleFunc("/api/v1/federation/share/create", h.federationShareCreate)
|
mux.HandleFunc("/api/v1/federation/share/create", h.federationShareCreate)
|
||||||
mux.HandleFunc("/api/v1/federation/share/delete", h.federationShareDelete)
|
mux.HandleFunc("/api/v1/federation/share/delete", h.federationShareDelete)
|
||||||
|
mux.HandleFunc("/api/v1/federation/share/reset-flow", h.federationShareResetFlow)
|
||||||
|
mux.HandleFunc("/api/v1/federation/share/remote-usage/list", h.federationRemoteUsageList)
|
||||||
mux.HandleFunc("/api/v1/federation/connect", h.authPeer(h.federationConnect))
|
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/tunnel/create", h.authPeer(h.federationTunnelCreate))
|
||||||
mux.HandleFunc("/api/v1/federation/runtime/reserve-port", h.authPeer(h.federationRuntimeReservePort))
|
mux.HandleFunc("/api/v1/federation/runtime/reserve-port", h.authPeer(h.federationRuntimeReservePort))
|
||||||
|
|||||||
@@ -1450,6 +1450,80 @@ func (r *Repository) GetPeerShareRuntimeByBindingID(shareID int64, bindingID str
|
|||||||
return &item, nil
|
return &item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetPeerShareRuntimeByID(id int64) (*PeerShareRuntime, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
row := r.db.QueryRow(`
|
||||||
|
SELECT id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time
|
||||||
|
FROM peer_share_runtime
|
||||||
|
WHERE id = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, id)
|
||||||
|
var item PeerShareRuntime
|
||||||
|
if err := row.Scan(&item.ID, &item.ShareID, &item.NodeID, &item.ReservationID, &item.ResourceKey, &item.BindingID, &item.Role, &item.ChainName, &item.ServiceName, &item.Protocol, &item.Strategy, &item.Port, &item.Target, &item.Applied, &item.Status, &item.CreatedTime, &item.UpdatedTime); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ListActivePeerShareRuntimesByShareID(shareID int64) ([]PeerShareRuntime, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
rows, err := r.db.Query(`
|
||||||
|
SELECT id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time
|
||||||
|
FROM peer_share_runtime
|
||||||
|
WHERE share_id = ? AND status = 1
|
||||||
|
ORDER BY port ASC, id ASC
|
||||||
|
`, shareID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]PeerShareRuntime, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var item PeerShareRuntime
|
||||||
|
if err := rows.Scan(&item.ID, &item.ShareID, &item.NodeID, &item.ReservationID, &item.ResourceKey, &item.BindingID, &item.Role, &item.ChainName, &item.ServiceName, &item.Protocol, &item.Strategy, &item.Port, &item.Target, &item.Applied, &item.Status, &item.CreatedTime, &item.UpdatedTime); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) AddPeerShareCurrentFlow(shareID int64, delta int64) error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
if shareID <= 0 || delta <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := r.db.Exec(`UPDATE peer_share SET current_flow = current_flow + ?, updated_time = ? WHERE id = ?`, delta, unixMilliNow(), shareID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ResetPeerShareCurrentFlow(shareID int64, updatedTime int64) error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
if shareID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if updatedTime <= 0 {
|
||||||
|
updatedTime = unixMilliNow()
|
||||||
|
}
|
||||||
|
_, err := r.db.Exec(`UPDATE peer_share SET current_flow = 0, updated_time = ? WHERE id = ?`, updatedTime, shareID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) CreatePeerShareRuntime(item *PeerShareRuntime) error {
|
func (r *Repository) CreatePeerShareRuntime(item *PeerShareRuntime) error {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return errors.New("repository not initialized")
|
return errors.New("repository not initialized")
|
||||||
|
|||||||
@@ -202,6 +202,10 @@ export const createPeerShare = (data: {
|
|||||||
}) => Network.post("/federation/share/create", data);
|
}) => Network.post("/federation/share/create", data);
|
||||||
export const deletePeerShare = (id: number) =>
|
export const deletePeerShare = (id: number) =>
|
||||||
Network.post("/federation/share/delete", { id });
|
Network.post("/federation/share/delete", { id });
|
||||||
|
export const resetPeerShareFlow = (id: number) =>
|
||||||
|
Network.post("/federation/share/reset-flow", { id });
|
||||||
|
export const getPeerRemoteUsageList = () =>
|
||||||
|
Network.post("/federation/share/remote-usage/list");
|
||||||
export const importRemoteNode = (data: {
|
export const importRemoteNode = (data: {
|
||||||
remoteUrl: string;
|
remoteUrl: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
|||||||
+175
-161
@@ -1114,9 +1114,12 @@ export default function NodePage() {
|
|||||||
strategy={rectSortingStrategy}
|
strategy={rectSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
{sortedNodes.map((node) => (
|
{sortedNodes.map((node) => {
|
||||||
<SortableItem key={node.id} id={node.id}>
|
const isRemoteNode = node.isRemote === 1;
|
||||||
{(listeners) => (
|
|
||||||
|
return (
|
||||||
|
<SortableItem key={node.id} id={node.id}>
|
||||||
|
{(listeners) => (
|
||||||
<Card
|
<Card
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
|
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
|
||||||
@@ -1135,16 +1138,6 @@ 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}
|
||||||
@@ -1159,6 +1152,16 @@ export default function NodePage() {
|
|||||||
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
|
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
{isRemoteNode && (
|
||||||
|
<Chip
|
||||||
|
className="text-xs"
|
||||||
|
color="secondary"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
远程
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
<Chip
|
<Chip
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
color={
|
color={
|
||||||
@@ -1215,164 +1218,174 @@ export default function NodePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
{!isRemoteNode && (
|
||||||
<span className="text-default-600">版本</span>
|
<>
|
||||||
<span className="text-xs">
|
<div className="flex justify-between text-sm">
|
||||||
{node.version || "未知"}
|
<span className="text-default-600">版本</span>
|
||||||
</span>
|
<span className="text-xs">
|
||||||
</div>
|
{node.version || "未知"}
|
||||||
<div className="flex justify-between text-sm">
|
</span>
|
||||||
<span className="text-default-600">开机时间</span>
|
</div>
|
||||||
<span className="text-xs">
|
<div className="flex justify-between text-sm">
|
||||||
{node.connectionStatus === "online" &&
|
<span className="text-default-600">开机时间</span>
|
||||||
node.systemInfo
|
<span className="text-xs">
|
||||||
? formatUptime(node.systemInfo.uptime)
|
|
||||||
: "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 系统监控 */}
|
|
||||||
<div className="space-y-3 mb-4">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-xs mb-1">
|
|
||||||
<span>CPU</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
{node.connectionStatus === "online" &&
|
{node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
|
? formatUptime(node.systemInfo.uptime)
|
||||||
: "-"}
|
: "-"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress
|
</>
|
||||||
aria-label="CPU使用率"
|
)}
|
||||||
color={getProgressColor(
|
|
||||||
node.connectionStatus === "online" &&
|
|
||||||
node.systemInfo
|
|
||||||
? node.systemInfo.cpuUsage
|
|
||||||
: 0,
|
|
||||||
node.connectionStatus !== "online",
|
|
||||||
)}
|
|
||||||
size="sm"
|
|
||||||
value={
|
|
||||||
node.connectionStatus === "online" &&
|
|
||||||
node.systemInfo
|
|
||||||
? node.systemInfo.cpuUsage
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-xs mb-1">
|
|
||||||
<span>内存</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
{node.connectionStatus === "online" &&
|
|
||||||
node.systemInfo
|
|
||||||
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
|
|
||||||
: "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
aria-label="内存使用率"
|
|
||||||
color={getProgressColor(
|
|
||||||
node.connectionStatus === "online" &&
|
|
||||||
node.systemInfo
|
|
||||||
? node.systemInfo.memoryUsage
|
|
||||||
: 0,
|
|
||||||
node.connectionStatus !== "online",
|
|
||||||
)}
|
|
||||||
size="sm"
|
|
||||||
value={
|
|
||||||
node.connectionStatus === "online" &&
|
|
||||||
node.systemInfo
|
|
||||||
? node.systemInfo.memoryUsage
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
|
||||||
<div className="text-default-600 mb-0.5">
|
|
||||||
上传
|
|
||||||
</div>
|
|
||||||
<div className="font-mono">
|
|
||||||
{node.connectionStatus === "online" &&
|
|
||||||
node.systemInfo
|
|
||||||
? formatSpeed(node.systemInfo.uploadSpeed)
|
|
||||||
: "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
|
||||||
<div className="text-default-600 mb-0.5">
|
|
||||||
下载
|
|
||||||
</div>
|
|
||||||
<div className="font-mono">
|
|
||||||
{node.connectionStatus === "online" &&
|
|
||||||
node.systemInfo
|
|
||||||
? formatSpeed(node.systemInfo.downloadSpeed)
|
|
||||||
: "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 流量统计 */}
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
|
|
||||||
<div className="text-primary-600 dark:text-primary-400 mb-0.5">
|
|
||||||
↑ 上行流量
|
|
||||||
</div>
|
|
||||||
<div className="font-mono text-primary-700 dark:text-primary-300">
|
|
||||||
{node.connectionStatus === "online" &&
|
|
||||||
node.systemInfo
|
|
||||||
? formatTraffic(node.systemInfo.uploadTraffic)
|
|
||||||
: "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
|
|
||||||
<div className="text-success-600 dark:text-success-400 mb-0.5">
|
|
||||||
↓ 下行流量
|
|
||||||
</div>
|
|
||||||
<div className="font-mono text-success-700 dark:text-success-300">
|
|
||||||
{node.connectionStatus === "online" &&
|
|
||||||
node.systemInfo
|
|
||||||
? formatTraffic(
|
|
||||||
node.systemInfo.downloadTraffic,
|
|
||||||
)
|
|
||||||
: "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isRemoteNode && (
|
||||||
|
<>
|
||||||
|
{/* 系统监控 */}
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-xs mb-1">
|
||||||
|
<span>CPU</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{node.connectionStatus === "online" &&
|
||||||
|
node.systemInfo
|
||||||
|
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
|
||||||
|
: "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
aria-label="CPU使用率"
|
||||||
|
color={getProgressColor(
|
||||||
|
node.connectionStatus === "online" &&
|
||||||
|
node.systemInfo
|
||||||
|
? node.systemInfo.cpuUsage
|
||||||
|
: 0,
|
||||||
|
node.connectionStatus !== "online",
|
||||||
|
)}
|
||||||
|
size="sm"
|
||||||
|
value={
|
||||||
|
node.connectionStatus === "online" &&
|
||||||
|
node.systemInfo
|
||||||
|
? node.systemInfo.cpuUsage
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-xs mb-1">
|
||||||
|
<span>内存</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{node.connectionStatus === "online" &&
|
||||||
|
node.systemInfo
|
||||||
|
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
|
||||||
|
: "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
aria-label="内存使用率"
|
||||||
|
color={getProgressColor(
|
||||||
|
node.connectionStatus === "online" &&
|
||||||
|
node.systemInfo
|
||||||
|
? node.systemInfo.memoryUsage
|
||||||
|
: 0,
|
||||||
|
node.connectionStatus !== "online",
|
||||||
|
)}
|
||||||
|
size="sm"
|
||||||
|
value={
|
||||||
|
node.connectionStatus === "online" &&
|
||||||
|
node.systemInfo
|
||||||
|
? node.systemInfo.memoryUsage
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
||||||
|
<div className="text-default-600 mb-0.5">
|
||||||
|
上传
|
||||||
|
</div>
|
||||||
|
<div className="font-mono">
|
||||||
|
{node.connectionStatus === "online" &&
|
||||||
|
node.systemInfo
|
||||||
|
? formatSpeed(node.systemInfo.uploadSpeed)
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
||||||
|
<div className="text-default-600 mb-0.5">
|
||||||
|
下载
|
||||||
|
</div>
|
||||||
|
<div className="font-mono">
|
||||||
|
{node.connectionStatus === "online" &&
|
||||||
|
node.systemInfo
|
||||||
|
? formatSpeed(node.systemInfo.downloadSpeed)
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 流量统计 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
|
||||||
|
<div className="text-primary-600 dark:text-primary-400 mb-0.5">
|
||||||
|
↑ 上行流量
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-primary-700 dark:text-primary-300">
|
||||||
|
{node.connectionStatus === "online" &&
|
||||||
|
node.systemInfo
|
||||||
|
? formatTraffic(node.systemInfo.uploadTraffic)
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
|
||||||
|
<div className="text-success-600 dark:text-success-400 mb-0.5">
|
||||||
|
↓ 下行流量
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-success-700 dark:text-success-300">
|
||||||
|
{node.connectionStatus === "online" &&
|
||||||
|
node.systemInfo
|
||||||
|
? formatTraffic(
|
||||||
|
node.systemInfo.downloadTraffic,
|
||||||
|
)
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
|
{!isRemoteNode && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className="flex-1 min-h-8"
|
||||||
|
color="success"
|
||||||
|
isLoading={node.copyLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleCopyInstallCommand(node)}
|
||||||
|
>
|
||||||
|
安装
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex-1 min-h-8"
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleEdit(node)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
className="flex-1 min-h-8"
|
className={`min-h-8 ${isRemoteNode ? "w-full" : "flex-1"}`}
|
||||||
color="success"
|
|
||||||
isDisabled={node.isRemote === 1}
|
|
||||||
isLoading={node.copyLoading}
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
onPress={() => handleCopyInstallCommand(node)}
|
|
||||||
>
|
|
||||||
安装
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1 min-h-8"
|
|
||||||
color="primary"
|
|
||||||
isDisabled={node.isRemote === 1}
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
onPress={() => handleEdit(node)}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1 min-h-8"
|
|
||||||
color="danger"
|
color="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -1386,7 +1399,8 @@ export default function NodePage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</SortableItem>
|
</SortableItem>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
createPeerShare,
|
createPeerShare,
|
||||||
getPeerShareList,
|
getPeerShareList,
|
||||||
deletePeerShare,
|
deletePeerShare,
|
||||||
|
resetPeerShareFlow,
|
||||||
|
getPeerRemoteUsageList,
|
||||||
importRemoteNode,
|
importRemoteNode,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
|
||||||
@@ -31,19 +33,61 @@ interface PeerShare {
|
|||||||
name: string;
|
name: string;
|
||||||
token: string;
|
token: string;
|
||||||
maxBandwidth: number;
|
maxBandwidth: number;
|
||||||
|
currentFlow: number;
|
||||||
expiryTime: number;
|
expiryTime: number;
|
||||||
portRangeStart: number;
|
portRangeStart: number;
|
||||||
portRangeEnd: number;
|
portRangeEnd: number;
|
||||||
isActive: number;
|
isActive: number;
|
||||||
allowedDomains?: string;
|
allowedDomains?: string;
|
||||||
allowedIps?: string;
|
allowedIps?: string;
|
||||||
|
usedPorts?: number[];
|
||||||
|
usedPortDetails?: Array<{
|
||||||
|
runtimeId: number;
|
||||||
|
port: number;
|
||||||
|
role: string;
|
||||||
|
protocol: string;
|
||||||
|
resourceKey: string;
|
||||||
|
applied: number;
|
||||||
|
updatedTime: number;
|
||||||
|
}>;
|
||||||
|
activeRuntimeNum?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteUsageBinding {
|
||||||
|
bindingId: number;
|
||||||
|
tunnelId: number;
|
||||||
|
tunnelName: string;
|
||||||
|
chainType: number;
|
||||||
|
hopInx: number;
|
||||||
|
allocatedPort: number;
|
||||||
|
resourceKey: string;
|
||||||
|
remoteBindingId: string;
|
||||||
|
updatedTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteUsageNode {
|
||||||
|
nodeId: number;
|
||||||
|
nodeName: string;
|
||||||
|
remoteUrl: string;
|
||||||
|
shareId: number;
|
||||||
|
portRangeStart: number;
|
||||||
|
portRangeEnd: number;
|
||||||
|
maxBandwidth: number;
|
||||||
|
currentFlow: number;
|
||||||
|
usedPorts: number[];
|
||||||
|
bindings: RemoteUsageBinding[];
|
||||||
|
activeBindingNum: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PanelSharingPage() {
|
export default function PanelSharingPage() {
|
||||||
const [selectedTab, setSelectedTab] = useState("my-shares");
|
const [selectedTab, setSelectedTab] = useState("my-shares");
|
||||||
const [shares, setShares] = useState<PeerShare[]>([]);
|
const [shares, setShares] = useState<PeerShare[]>([]);
|
||||||
|
const [remoteUsageNodes, setRemoteUsageNodes] = useState<RemoteUsageNode[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
const [nodes, setNodes] = useState<Node[]>([]);
|
const [nodes, setNodes] = useState<Node[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [remoteUsageLoading, setRemoteUsageLoading] = useState(false);
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
const [createShareOpen, setCreateShareOpen] = useState(false);
|
const [createShareOpen, setCreateShareOpen] = useState(false);
|
||||||
@@ -103,12 +147,30 @@ export default function PanelSharingPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadRemoteUsage = useCallback(async () => {
|
||||||
|
setRemoteUsageLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getPeerRemoteUsageList();
|
||||||
|
if (res.code === 0) {
|
||||||
|
setRemoteUsageNodes(res.data || []);
|
||||||
|
} else {
|
||||||
|
toast.error(res.msg || "加载远程占用端口失败");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setRemoteUsageLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTab === "my-shares") {
|
if (selectedTab === "my-shares") {
|
||||||
loadShares();
|
loadShares();
|
||||||
loadNodes();
|
loadNodes();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [selectedTab, loadShares, loadNodes]);
|
if (selectedTab === "remote-nodes") {
|
||||||
|
loadRemoteUsage();
|
||||||
|
}
|
||||||
|
}, [selectedTab, loadShares, loadNodes, loadRemoteUsage]);
|
||||||
|
|
||||||
const handleCreateShare = async () => {
|
const handleCreateShare = async () => {
|
||||||
if (!shareForm.name || !shareForm.nodeId) {
|
if (!shareForm.name || !shareForm.nodeId) {
|
||||||
@@ -120,13 +182,17 @@ export default function PanelSharingPage() {
|
|||||||
toast.error("仅可选择本地节点");
|
toast.error("仅可选择本地节点");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (shareForm.maxBandwidth < 0) {
|
||||||
|
toast.error("流量上限不能为负数");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const expiryTime =
|
const expiryTime =
|
||||||
Date.now() + shareForm.expiryDays * 24 * 60 * 60 * 1000;
|
Date.now() + shareForm.expiryDays * 24 * 60 * 60 * 1000;
|
||||||
const res = await createPeerShare({
|
const res = await createPeerShare({
|
||||||
name: shareForm.name,
|
name: shareForm.name,
|
||||||
nodeId,
|
nodeId,
|
||||||
maxBandwidth: shareForm.maxBandwidth * 1024 * 1024 * 1024,
|
maxBandwidth: Math.max(0, shareForm.maxBandwidth) * 1024 * 1024 * 1024,
|
||||||
expiryTime: shareForm.expiryDays === 0 ? 0 : expiryTime,
|
expiryTime: shareForm.expiryDays === 0 ? 0 : expiryTime,
|
||||||
portRangeStart: shareForm.portRangeStart,
|
portRangeStart: shareForm.portRangeStart,
|
||||||
portRangeEnd: shareForm.portRangeEnd,
|
portRangeEnd: shareForm.portRangeEnd,
|
||||||
@@ -159,6 +225,20 @@ export default function PanelSharingPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetShareFlow = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const res = await resetPeerShareFlow(id);
|
||||||
|
if (res.code === 0) {
|
||||||
|
toast.success("共享流量已重置");
|
||||||
|
loadShares();
|
||||||
|
} else {
|
||||||
|
toast.error(res.msg || "重置流量失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("网络错误");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleImportNode = async () => {
|
const handleImportNode = async () => {
|
||||||
if (!importForm.remoteUrl || !importForm.token) {
|
if (!importForm.remoteUrl || !importForm.token) {
|
||||||
toast.error("请填写完整信息");
|
toast.error("请填写完整信息");
|
||||||
@@ -179,6 +259,7 @@ export default function PanelSharingPage() {
|
|||||||
toast.success("导入成功,请前往节点列表查看");
|
toast.success("导入成功,请前往节点列表查看");
|
||||||
setImportNodeOpen(false);
|
setImportNodeOpen(false);
|
||||||
setImportForm({ remoteUrl: "", token: "" });
|
setImportForm({ remoteUrl: "", token: "" });
|
||||||
|
loadRemoteUsage();
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.msg || "导入失败");
|
toast.error(res.msg || "导入失败");
|
||||||
}
|
}
|
||||||
@@ -192,6 +273,23 @@ export default function PanelSharingPage() {
|
|||||||
toast.success("Token已复制");
|
toast.success("Token已复制");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatFlowGB = (bytes: number) => {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||||
|
return "0.00 GB";
|
||||||
|
}
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatChainType = (chainType: number, hopInx: number) => {
|
||||||
|
if (chainType === 2) {
|
||||||
|
return `中继跳点 #${hopInx}`;
|
||||||
|
}
|
||||||
|
if (chainType === 3) {
|
||||||
|
return "出口节点";
|
||||||
|
}
|
||||||
|
return "未知链路";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6 space-y-6">
|
<div className="p-4 md:p-6 space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
@@ -222,10 +320,31 @@ export default function PanelSharingPage() {
|
|||||||
<Card key={share.id} className="border border-divider shadow-sm">
|
<Card key={share.id} className="border border-divider shadow-sm">
|
||||||
<CardHeader className="flex justify-between">
|
<CardHeader className="flex justify-between">
|
||||||
<h3 className="font-bold">{share.name}</h3>
|
<h3 className="font-bold">{share.name}</h3>
|
||||||
<Button size="sm" color="danger" variant="flat" onPress={() => handleDeleteShare(share.id)}>删除</Button>
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleResetShareFlow(share.id)}
|
||||||
|
>
|
||||||
|
重置流量
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" color="danger" variant="flat" onPress={() => handleDeleteShare(share.id)}>删除</Button>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody className="text-sm space-y-2">
|
<CardBody className="text-sm space-y-2">
|
||||||
<p>端口范围: {share.portRangeStart} - {share.portRangeEnd}</p>
|
<p>端口范围: {share.portRangeStart} - {share.portRangeEnd}</p>
|
||||||
|
<p>流量上限: {share.maxBandwidth > 0 ? formatFlowGB(share.maxBandwidth) : "不限制"}</p>
|
||||||
|
<p>当前流量: {formatFlowGB(share.currentFlow || 0)}</p>
|
||||||
|
<p>远程占用端口: {share.usedPorts && share.usedPorts.length > 0 ? share.usedPorts.join(", ") : "暂无"}</p>
|
||||||
|
{share.usedPortDetails && share.usedPortDetails.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{share.usedPortDetails.map((item) => (
|
||||||
|
<span key={item.runtimeId} className="text-xs rounded-full px-2 py-1 bg-default-100">
|
||||||
|
{item.port} / {item.role || "reserved"}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{share.allowedDomains && <p>允许域名: {share.allowedDomains}</p>}
|
{share.allowedDomains && <p>允许域名: {share.allowedDomains}</p>}
|
||||||
{share.allowedIps && <p>允许API IP: {share.allowedIps}</p>}
|
{share.allowedIps && <p>允许API IP: {share.allowedIps}</p>}
|
||||||
<p>过期时间: {share.expiryTime === 0 ? "永久" : new Date(share.expiryTime).toLocaleDateString()}</p>
|
<p>过期时间: {share.expiryTime === 0 ? "永久" : new Date(share.expiryTime).toLocaleDateString()}</p>
|
||||||
@@ -249,10 +368,46 @@ export default function PanelSharingPage() {
|
|||||||
导入远程节点
|
导入远程节点
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center py-10 text-gray-500">
|
|
||||||
<p>已导入的节点将显示在“节点管理”页面,带有“远程”标记。</p>
|
{remoteUsageLoading ? (
|
||||||
<p className="mt-2">请使用其创建隧道。</p>
|
<div className="text-center py-10 text-gray-500">加载中...</div>
|
||||||
</div>
|
) : remoteUsageNodes.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-gray-500">
|
||||||
|
<p>暂无远程节点占用记录。</p>
|
||||||
|
<p className="mt-2">导入远程节点并创建隧道后,这里会显示远端端口占用情况。</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{remoteUsageNodes.map((node) => (
|
||||||
|
<Card key={node.nodeId} className="border border-divider shadow-sm">
|
||||||
|
<CardHeader className="flex justify-between">
|
||||||
|
<h3 className="font-bold">{node.nodeName}</h3>
|
||||||
|
<span className="text-xs text-default-500">绑定 {node.activeBindingNum || 0}</span>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="text-sm space-y-2">
|
||||||
|
{node.remoteUrl && <p>远程地址: {node.remoteUrl}</p>}
|
||||||
|
<p>共享ID: {node.shareId || "-"}</p>
|
||||||
|
<p>端口范围: {node.portRangeStart > 0 && node.portRangeEnd > 0 ? `${node.portRangeStart} - ${node.portRangeEnd}` : "-"}</p>
|
||||||
|
<p>共享流量: {node.maxBandwidth > 0 ? `${formatFlowGB(node.currentFlow || 0)} / ${formatFlowGB(node.maxBandwidth)}` : `${formatFlowGB(node.currentFlow || 0)} / 不限制`}</p>
|
||||||
|
<p>远端占用端口: {node.usedPorts && node.usedPorts.length > 0 ? node.usedPorts.join(", ") : "暂无"}</p>
|
||||||
|
{node.bindings && node.bindings.length > 0 && (
|
||||||
|
<div className="space-y-1 pt-1">
|
||||||
|
{node.bindings.map((binding) => (
|
||||||
|
<p key={binding.bindingId} className="text-xs text-default-600">
|
||||||
|
隧道 {binding.tunnelName || `#${binding.tunnelId}`}
|
||||||
|
{" · "}
|
||||||
|
端口 {binding.allocatedPort}
|
||||||
|
{" · "}
|
||||||
|
{formatChainType(binding.chainType, binding.hopInx)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</Tab>
|
</Tab>
|
||||||
@@ -302,6 +457,13 @@ export default function PanelSharingPage() {
|
|||||||
value={shareForm.expiryDays.toString()}
|
value={shareForm.expiryDays.toString()}
|
||||||
onChange={(e) => setShareForm({ ...shareForm, expiryDays: parseInt(e.target.value) })}
|
onChange={(e) => setShareForm({ ...shareForm, expiryDays: parseInt(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
label="流量上限 (GB)"
|
||||||
|
type="number"
|
||||||
|
description="0 表示不限流量"
|
||||||
|
value={shareForm.maxBandwidth.toString()}
|
||||||
|
onChange={(e) => setShareForm({ ...shareForm, maxBandwidth: parseInt(e.target.value, 10) || 0 })}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="允许的域名 (可选)"
|
label="允许的域名 (可选)"
|
||||||
placeholder="example.com, panel.test.com"
|
placeholder="example.com, panel.test.com"
|
||||||
|
|||||||
Reference in New Issue
Block a user