feat(federation): add share flow reset and remote usage visibility

This commit is contained in:
sagit
2026-02-10 10:04:15 +00:00
parent 34becb6604
commit 3b697f4d13
10 changed files with 965 additions and 14 deletions
+268 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/http"
"sort"
"strings"
"time"
@@ -35,6 +36,10 @@ type deletePeerShareRequest struct {
ID int64 `json:"id"`
}
type resetPeerShareFlowRequest struct {
ID int64 `json:"id"`
}
type nodeImportRequest struct {
RemoteURL string `json:"remoteUrl"`
Token string `json:"token"`
@@ -74,6 +79,49 @@ type federationRuntimeDiagnoseRequest struct {
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) {
if r.Method != http.MethodPost {
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()))
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) {
@@ -191,6 +287,153 @@ func (h *Handler) federationShareDelete(w http.ResponseWriter, r *http.Request)
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) {
if r.Method != http.MethodPost {
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{}{
"shareId": info.ShareID,
"maxBandwidth": info.MaxBandwidth,
"currentFlow": info.CurrentFlow,
"expiryTime": info.ExpiryTime,
"portRangeStart": info.PortRangeStart,
"portRangeEnd": info.PortRangeEnd,
@@ -370,6 +614,7 @@ func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
"serverIp": serverIP,
"status": status,
"maxBandwidth": share.MaxBandwidth,
"currentFlow": share.CurrentFlow,
"expiryTime": share.ExpiryTime,
"portRangeStart": share.PortRangeStart,
"portRangeEnd": share.PortRangeEnd,
@@ -388,6 +633,10 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
response.WriteJSON(w, response.Err(401, "Unauthorized"))
return
}
if isPeerShareFlowExceeded(share) {
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
return
}
var req federationTunnelRequest
if err := decodeJSON(r.Body, &req); err != nil {
@@ -488,6 +737,10 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
}))
return
}
if isPeerShareFlowExceeded(share) {
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
return
}
allocatedPort, err := h.pickPeerSharePort(share, req.RequestedPort)
if err != nil {
@@ -597,6 +850,10 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
}))
return
}
if isPeerShareFlowExceeded(share) {
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
return
}
node, err := h.getNodeRecord(share.NodeID)
if err != nil {
@@ -885,6 +1142,16 @@ func extractBearerToken(r *http.Request) string {
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) {
raw = strings.TrimSpace(raw)
if raw == "" {
@@ -1,10 +1,15 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"go-backend/internal/http/response"
"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)
}
}
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) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
@@ -38,16 +38,21 @@ func (h *Handler) processFlowItem(item flowItem) {
}
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
}
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
if userTunnelID > 0 {
h.enforceFlowPolicies(userID, userTunnelID)
runtimeID, ok := parsePeerShareRuntimeServiceID(serviceName)
if !ok {
return
}
h.processPeerShareFlow(runtimeID, item)
}
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
}
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) {
forward, err := h.getForwardRecord(forwardID)
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/create", h.federationShareCreate)
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/tunnel/create", h.authPeer(h.federationTunnelCreate))
mux.HandleFunc("/api/v1/federation/runtime/reserve-port", h.authPeer(h.federationRuntimeReservePort))