feat: 通道组——一次开放多台主机 / 多个端口,共用邀请与授权

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

No files matched your search

+277
View File
@@ -0,0 +1,277 @@
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
}