diff --git a/go-backend/internal/http/client/federation.go b/go-backend/internal/http/client/federation.go index 8d5c0e9..fdc04ee 100644 --- a/go-backend/internal/http/client/federation.go +++ b/go-backend/internal/http/client/federation.go @@ -21,6 +21,7 @@ type RemoteNodeInfo struct { ServerIP string `json:"serverIp"` Status int `json:"status"` MaxBandwidth int64 `json:"maxBandwidth"` + CurrentFlow int64 `json:"currentFlow"` ExpiryTime int64 `json:"expiryTime"` PortRangeStart int `json:"portRangeStart"` PortRangeEnd int `json:"portRangeEnd"` diff --git a/go-backend/internal/http/handler/federation.go b/go-backend/internal/http/handler/federation.go index 602dfc6..874b837 100644 --- a/go-backend/internal/http/handler/federation.go +++ b/go-backend/internal/http/handler/federation.go @@ -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 == "" { diff --git a/go-backend/internal/http/handler/federation_runtime_test.go b/go-backend/internal/http/handler/federation_runtime_test.go index 8aa7cc8..b29f758 100644 --- a/go-backend/internal/http/handler/federation_runtime_test.go +++ b/go-backend/internal/http/handler/federation_runtime_test.go @@ -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) + } +} diff --git a/go-backend/internal/http/handler/federation_share_test.go b/go-backend/internal/http/handler/federation_share_test.go index 4e7b4da..ad29190 100644 --- a/go-backend/internal/http/handler/federation_share_test.go +++ b/go-backend/internal/http/handler/federation_share_test.go @@ -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 { diff --git a/go-backend/internal/http/handler/flow_policy.go b/go-backend/internal/http/handler/flow_policy.go index 5efdb18..0c00e96 100644 --- a/go-backend/internal/http/handler/flow_policy.go +++ b/go-backend/internal/http/handler/flow_policy.go @@ -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 { diff --git a/go-backend/internal/http/handler/flow_policy_federation_test.go b/go-backend/internal/http/handler/flow_policy_federation_test.go new file mode 100644 index 0000000..d3814f5 --- /dev/null +++ b/go-backend/internal/http/handler/flow_policy_federation_test.go @@ -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) + } +} diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 94451ee..4bfec49 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -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)) diff --git a/go-backend/internal/store/sqlite/repository.go b/go-backend/internal/store/sqlite/repository.go index 9051f64..440c4ba 100644 --- a/go-backend/internal/store/sqlite/repository.go +++ b/go-backend/internal/store/sqlite/repository.go @@ -1450,6 +1450,80 @@ func (r *Repository) GetPeerShareRuntimeByBindingID(shareID int64, bindingID str 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 { if r == nil || r.db == nil { return errors.New("repository not initialized") diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index effec4e..f0b56d2 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -202,6 +202,10 @@ export const createPeerShare = (data: { }) => Network.post("/federation/share/create", data); export const deletePeerShare = (id: number) => 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: { remoteUrl: string; token: string; diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 9ffe687..178c0b5 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -1114,9 +1114,12 @@ export default function NodePage() { strategy={rectSortingStrategy} >
- {sortedNodes.map((node) => ( - - {(listeners) => ( + {sortedNodes.map((node) => { + const isRemoteNode = node.isRemote === 1; + + return ( + + {(listeners) => (
- {node.isRemote === 1 && ( - - 远程 - - )}
+ {isRemoteNode && ( + + 远程 + + )}
-
- 版本 - - {node.version || "未知"} - -
-
- 开机时间 - - {node.connectionStatus === "online" && - node.systemInfo - ? formatUptime(node.systemInfo.uptime) - : "-"} - -
- - - {/* 系统监控 */} -
-
-
-
- CPU - + {!isRemoteNode && ( + <> +
+ 版本 + + {node.version || "未知"} + +
+
+ 开机时间 + {node.connectionStatus === "online" && node.systemInfo - ? `${node.systemInfo.cpuUsage.toFixed(1)}%` + ? formatUptime(node.systemInfo.uptime) : "-"}
- -
-
-
- 内存 - - {node.connectionStatus === "online" && - node.systemInfo - ? `${node.systemInfo.memoryUsage.toFixed(1)}%` - : "-"} - -
- -
-
- -
-
-
- 上传 -
-
- {node.connectionStatus === "online" && - node.systemInfo - ? formatSpeed(node.systemInfo.uploadSpeed) - : "-"} -
-
-
-
- 下载 -
-
- {node.connectionStatus === "online" && - node.systemInfo - ? formatSpeed(node.systemInfo.downloadSpeed) - : "-"} -
-
-
- - {/* 流量统计 */} -
-
-
- ↑ 上行流量 -
-
- {node.connectionStatus === "online" && - node.systemInfo - ? formatTraffic(node.systemInfo.uploadTraffic) - : "-"} -
-
-
-
- ↓ 下行流量 -
-
- {node.connectionStatus === "online" && - node.systemInfo - ? formatTraffic( - node.systemInfo.downloadTraffic, - ) - : "-"} -
-
-
+ + )}
+ {!isRemoteNode && ( + <> + {/* 系统监控 */} +
+
+
+
+ CPU + + {node.connectionStatus === "online" && + node.systemInfo + ? `${node.systemInfo.cpuUsage.toFixed(1)}%` + : "-"} + +
+ +
+
+
+ 内存 + + {node.connectionStatus === "online" && + node.systemInfo + ? `${node.systemInfo.memoryUsage.toFixed(1)}%` + : "-"} + +
+ +
+
+ +
+
+
+ 上传 +
+
+ {node.connectionStatus === "online" && + node.systemInfo + ? formatSpeed(node.systemInfo.uploadSpeed) + : "-"} +
+
+
+
+ 下载 +
+
+ {node.connectionStatus === "online" && + node.systemInfo + ? formatSpeed(node.systemInfo.downloadSpeed) + : "-"} +
+
+
+ + {/* 流量统计 */} +
+
+
+ ↑ 上行流量 +
+
+ {node.connectionStatus === "online" && + node.systemInfo + ? formatTraffic(node.systemInfo.uploadTraffic) + : "-"} +
+
+
+
+ ↓ 下行流量 +
+
+ {node.connectionStatus === "online" && + node.systemInfo + ? formatTraffic( + node.systemInfo.downloadTraffic, + ) + : "-"} +
+
+
+
+ + )} + {/* 操作按钮 */}
+ {!isRemoteNode && ( + <> + + + + )} - -
diff --git a/vite-frontend/src/pages/panel-sharing.tsx b/vite-frontend/src/pages/panel-sharing.tsx index e9afeae..8bc0d91 100644 --- a/vite-frontend/src/pages/panel-sharing.tsx +++ b/vite-frontend/src/pages/panel-sharing.tsx @@ -17,6 +17,8 @@ import { createPeerShare, getPeerShareList, deletePeerShare, + resetPeerShareFlow, + getPeerRemoteUsageList, importRemoteNode, } from "@/api"; @@ -31,19 +33,61 @@ interface PeerShare { name: string; token: string; maxBandwidth: number; + currentFlow: number; expiryTime: number; portRangeStart: number; portRangeEnd: number; isActive: number; allowedDomains?: 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() { const [selectedTab, setSelectedTab] = useState("my-shares"); const [shares, setShares] = useState([]); + const [remoteUsageNodes, setRemoteUsageNodes] = useState( + [], + ); const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(false); + const [remoteUsageLoading, setRemoteUsageLoading] = useState(false); // Modals 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(() => { if (selectedTab === "my-shares") { loadShares(); loadNodes(); + return; } - }, [selectedTab, loadShares, loadNodes]); + if (selectedTab === "remote-nodes") { + loadRemoteUsage(); + } + }, [selectedTab, loadShares, loadNodes, loadRemoteUsage]); const handleCreateShare = async () => { if (!shareForm.name || !shareForm.nodeId) { @@ -120,13 +182,17 @@ export default function PanelSharingPage() { toast.error("仅可选择本地节点"); return; } + if (shareForm.maxBandwidth < 0) { + toast.error("流量上限不能为负数"); + return; + } try { const expiryTime = Date.now() + shareForm.expiryDays * 24 * 60 * 60 * 1000; const res = await createPeerShare({ name: shareForm.name, nodeId, - maxBandwidth: shareForm.maxBandwidth * 1024 * 1024 * 1024, + maxBandwidth: Math.max(0, shareForm.maxBandwidth) * 1024 * 1024 * 1024, expiryTime: shareForm.expiryDays === 0 ? 0 : expiryTime, portRangeStart: shareForm.portRangeStart, 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 () => { if (!importForm.remoteUrl || !importForm.token) { toast.error("请填写完整信息"); @@ -179,6 +259,7 @@ export default function PanelSharingPage() { toast.success("导入成功,请前往节点列表查看"); setImportNodeOpen(false); setImportForm({ remoteUrl: "", token: "" }); + loadRemoteUsage(); } else { toast.error(res.msg || "导入失败"); } @@ -192,6 +273,23 @@ export default function PanelSharingPage() { 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 (
@@ -222,10 +320,31 @@ export default function PanelSharingPage() {

{share.name}

- +
+ + +

端口范围: {share.portRangeStart} - {share.portRangeEnd}

+

流量上限: {share.maxBandwidth > 0 ? formatFlowGB(share.maxBandwidth) : "不限制"}

+

当前流量: {formatFlowGB(share.currentFlow || 0)}

+

远程占用端口: {share.usedPorts && share.usedPorts.length > 0 ? share.usedPorts.join(", ") : "暂无"}

+ {share.usedPortDetails && share.usedPortDetails.length > 0 && ( +
+ {share.usedPortDetails.map((item) => ( + + {item.port} / {item.role || "reserved"} + + ))} +
+ )} {share.allowedDomains &&

允许域名: {share.allowedDomains}

} {share.allowedIps &&

允许API IP: {share.allowedIps}

}

过期时间: {share.expiryTime === 0 ? "永久" : new Date(share.expiryTime).toLocaleDateString()}

@@ -249,10 +368,46 @@ export default function PanelSharingPage() { 导入远程节点
-
-

已导入的节点将显示在“节点管理”页面,带有“远程”标记。

-

请使用其创建隧道。

-
+ + {remoteUsageLoading ? ( +
加载中...
+ ) : remoteUsageNodes.length === 0 ? ( +
+

暂无远程节点占用记录。

+

导入远程节点并创建隧道后,这里会显示远端端口占用情况。

+
+ ) : ( +
+ {remoteUsageNodes.map((node) => ( + + +

{node.nodeName}

+ 绑定 {node.activeBindingNum || 0} +
+ + {node.remoteUrl &&

远程地址: {node.remoteUrl}

} +

共享ID: {node.shareId || "-"}

+

端口范围: {node.portRangeStart > 0 && node.portRangeEnd > 0 ? `${node.portRangeStart} - ${node.portRangeEnd}` : "-"}

+

共享流量: {node.maxBandwidth > 0 ? `${formatFlowGB(node.currentFlow || 0)} / ${formatFlowGB(node.maxBandwidth)}` : `${formatFlowGB(node.currentFlow || 0)} / 不限制`}

+

远端占用端口: {node.usedPorts && node.usedPorts.length > 0 ? node.usedPorts.join(", ") : "暂无"}

+ {node.bindings && node.bindings.length > 0 && ( +
+ {node.bindings.map((binding) => ( +

+ 隧道 {binding.tunnelName || `#${binding.tunnelId}`} + {" · "} + 端口 {binding.allocatedPort} + {" · "} + {formatChainType(binding.chainType, binding.hopInx)} +

+ ))} +
+ )} +
+
+ ))} +
+ )} @@ -302,6 +457,13 @@ export default function PanelSharingPage() { value={shareForm.expiryDays.toString()} onChange={(e) => setShareForm({ ...shareForm, expiryDays: parseInt(e.target.value) })} /> + setShareForm({ ...shareForm, maxBandwidth: parseInt(e.target.value, 10) || 0 })} + />