Files
PortRelay/client/api_groups.go
T
DeepFal 3797bef88a feat: 通道组——一次开放多台主机 / 多个端口,共用邀请与授权
- 批量创建:主机支持 IP、主机名、末段范围(10.0.1.11-13)和 主机:端口,
  与端口列表展开为 主机 × 端口,自动分配连续的访问端口,创建前可预览
- 授权以组为单位:一条邀请链接、一次审批即可访问组内全部地址;
  计时从首次连上任一地址开始,撤销一次全组断开;后加入组的通道自动获得组授权
- 网关授权状态按作用范围(通道 / 通道组)存储,通道的动态白名单统一由状态库重新计算,
  通道授权与组授权互不干扰;组内最后一个通道关闭或组被解散时,组的邀请与授权失效
- 闲置联动:组内任一地址在用时整组都不算闲置;支持整组开关
- 已有通道可并入组或移出组;组可改名、追加目标、解散(保留或删除成员)
- App 与网页面板:组卡片、新建 / 编辑通道组、组邀请与“连接与授权”,授权页列出全部地址
- 测试:网关 9 个、Agent 5 个通道组用例
2026-10-13 20:16:43 +08:00

278 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package client
import (
"encoding/json"
"io"
"net/http"
"strings"
"github.com/DeepFal/PortRelay/core/protocol"
)
// GroupView joins a tunnel group with its members and live shared access state
type GroupView struct {
TunnelGroup
TunnelIDs []string `json:"tunnel_ids"` // 成员,按监听端口排序
Metrics protocol.GroupMetrics `json:"metrics"`
// RateHistory 开启中成员的近 10 分钟流量之和
RateHistory *RateSeries `json:"rate_history,omitempty"`
}
func (s *APIServer) groupsView(metrics protocol.MetricsMessage) []GroupView {
shared := make(map[string]protocol.GroupMetrics, len(metrics.Groups))
for _, g := range metrics.Groups {
shared[g.GroupID] = g
}
cfg := s.cm.Get()
out := make([]GroupView, 0, len(cfg.Groups))
for _, g := range cfg.Groups {
v := GroupView{TunnelGroup: g, TunnelIDs: make([]string, 0), Metrics: protocol.GroupMetrics{GroupID: g.ID}}
if m, ok := shared[g.ID]; ok {
v.Metrics = m
}
var open []string
for _, t := range s.cm.GroupMembers(g.ID) {
v.TunnelIDs = append(v.TunnelIDs, t.ID)
if t.Enabled {
open = append(open, t.ID)
}
}
if len(open) > 0 {
series := s.agent.GroupRateSeries(open)
v.RateHistory = &series
}
out = append(out, v)
}
return out
}
// groupRequest creates a group or adds members to one
type groupRequest struct {
Name string `json:"name"`
Owner string `json:"owner"`
// Hosts / Ports 展开为新建成员,见 ExpandTargets
Hosts string `json:"hosts"`
Ports string `json:"ports"`
// TunnelIDs 加入该组的已有通道
TunnelIDs []string `json:"tunnel_ids"`
// 新建成员的通道设置
Whitelist []string `json:"whitelist"`
IdleCloseMinutes *int `json:"idle_close_minutes"`
DurationMinutes int `json:"duration_minutes"`
MaxBandwidthMB float64 `json:"max_bandwidth_mb"`
}
func (req groupRequest) spec() (GroupSpec, error) {
spec := GroupSpec{
Name: req.Name,
Owner: req.Owner,
MoveIDs: req.TunnelIDs,
Template: protocol.TunnelConfig{
Whitelist: req.Whitelist,
IdleCloseMinutes: req.IdleCloseMinutes,
DurationMinutes: req.DurationMinutes,
MaxBandwidthMB: req.MaxBandwidthMB,
},
}
if strings.TrimSpace(req.Hosts) != "" {
targets, err := ExpandTargets(req.Hosts, req.Ports)
if err != nil {
return spec, err
}
spec.Targets = targets
}
return spec, nil
}
func decodeGroupRequest(r *http.Request) (groupRequest, error) {
var req groupRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err == io.EOF {
err = nil
}
return req, err
}
// syncGroupChanges pushes newly created members and changed existing ones to the relay server
func (s *APIServer) syncGroupChanges(created, changed []protocol.TunnelConfig) {
for _, t := range created {
s.agent.SyncTunnel(t, false)
}
for _, t := range changed {
if t.Enabled {
s.agent.SyncTunnel(t, true)
}
}
}
// handleGroups: GET 列表;POST 新建通道组(批量新建目标 + / 或加入已有通道)
func (s *APIServer) handleGroups(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, s.groupsView(s.agent.GetLatestMetrics()))
case http.MethodPost:
req, err := decodeGroupRequest(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
spec, err := req.spec()
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
g, created, changed, err := s.cm.SaveGroup("", spec)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
s.syncGroupChanges(created, changed)
jsonResponse(w, http.StatusCreated, map[string]any{"group": g, "created": nonNil(created)})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// handleGroupItem:
//
// POST /api/groups/preview 预览批量展开结果(不保存)
// PUT /api/groups/{id} 改名 / 责任人,并可追加成员
// DELETE /api/groups/{id}?delete_members=1 解散(默认保留成员为独立通道)
// POST /api/groups/{id}/toggle {"enabled": bool} 整组开关
// ... /api/groups/{id}/leases|invites|requests 与单个通道相同的授权接口
func (s *APIServer) handleGroupItem(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 3 || parts[2] == "" {
jsonError(w, http.StatusBadRequest, "Missing group ID")
return
}
if parts[2] == "preview" && len(parts) == 3 && r.Method == http.MethodPost {
s.handleGroupPreview(w, r)
return
}
id := parts[2]
g, ok := s.cm.FindGroup(id)
if !ok {
jsonError(w, http.StatusNotFound, "通道组不存在")
return
}
if len(parts) >= 4 && isAccessPath(parts[3]) {
s.handleAccess(w, r, Scope{GroupID: id}, parts[3:])
return
}
if len(parts) == 4 && parts[3] == "toggle" && r.Method == http.MethodPost {
want, err := decodeToggle(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if want == nil {
// 不指定时:有任一成员开启则整组关闭,否则整组开启
on := false
for _, t := range s.cm.GroupMembers(id) {
on = on || t.Enabled
}
flip := !on
want = &flip
}
out := make([]protocol.TunnelConfig, 0)
for _, t := range s.cm.GroupMembers(id) {
updated, err := s.setEnabled(t.ID, want)
if err != nil {
jsonError(w, http.StatusConflict, err.Error())
return
}
out = append(out, updated)
}
jsonResponse(w, http.StatusOK, out)
return
}
if len(parts) != 3 {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodPut:
req, err := decodeGroupRequest(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if strings.TrimSpace(req.Name) == "" {
req.Name = g.Name
}
spec, err := req.spec()
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
updated, created, changed, err := s.cm.SaveGroup(id, spec)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
s.syncGroupChanges(created, changed)
jsonResponse(w, http.StatusOK, map[string]any{"group": updated, "created": nonNil(created)})
case http.MethodDelete:
deleteMembers := r.URL.Query().Get("delete_members") == "1"
members, err := s.cm.RemoveGroup(id, deleteMembers)
if err != nil {
jsonError(w, http.StatusNotFound, err.Error())
return
}
for _, t := range members {
if deleteMembers {
s.agent.ConsumeSessionBytes(t.ID)
s.agent.dropUsage(t.ID)
s.agent.RemoveTunnel(t.ID)
} else if t.Enabled {
s.agent.SyncTunnel(t, true)
}
}
s.agent.DeleteGroup(id)
jsonResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// handleGroupPreview expands hosts × ports and assigns ports without saving
func (s *APIServer) handleGroupPreview(w http.ResponseWriter, r *http.Request) {
req, err := decodeGroupRequest(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
spec, err := req.spec()
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
members, err := s.cm.PreviewGroupMembers(spec)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
type item struct {
Name string `json:"name"`
Target string `json:"target"`
ListenPort int `json:"listen_port"`
}
out := make([]item, 0, len(members))
for _, t := range members {
out = append(out, item{t.Name, t.Target, t.ListenPort})
}
jsonResponse(w, http.StatusOK, map[string]any{"members": out})
}
func nonNil(ts []protocol.TunnelConfig) []protocol.TunnelConfig {
if ts == nil {
return []protocol.TunnelConfig{}
}
return ts
}