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

+250
View File
@@ -11,6 +11,7 @@ import (
"net"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
@@ -49,12 +50,21 @@ func generateAuthCode() string {
return string(b)
}
// TunnelGroup 通道组:组内通道共用邀请、授权与审批,闲置计时联动
type TunnelGroup struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Config represents persistent client settings
type Config struct {
ServerAddr string `json:"server_addr"` // 中继服务器控制地址 (host:port)
Token string `json:"token"`
PublicHost string `json:"public_host,omitempty"` // 访问者连接通道时使用的网关地址
Tunnels []protocol.TunnelConfig `json:"tunnels"`
Groups []TunnelGroup `json:"groups,omitempty"`
ClientHistory []protocol.ClientHistoryRecord `json:"client_history,omitempty"`
}
@@ -208,6 +218,7 @@ func (cm *ConfigManager) Get() Config {
for i, t := range cm.cfg.Tunnels {
out.Tunnels[i] = cloneTunnel(t)
}
out.Groups = append([]TunnelGroup(nil), cm.cfg.Groups...)
out.ClientHistory = append([]protocol.ClientHistoryRecord(nil), cm.cfg.ClientHistory...)
return out
}
@@ -316,9 +327,24 @@ func normalizeAndValidate(t *protocol.TunnelConfig) error {
// 放通时长与是否审批已改为在每个邀请上设置,通道级字段不再使用
t.LeaseDuration = ""
t.RequireApproval = false
// 组名只在同步到服务端时填写,配置里以组 ID 为准
t.GroupName = ""
return nil
}
// groupExistsLocked reports whether a group ID is known ("" means no group). Caller must hold cm.mu.
func (cm *ConfigManager) groupExistsLocked(id string) bool {
if id == "" {
return true
}
for _, g := range cm.cfg.Groups {
if g.ID == id {
return true
}
}
return false
}
// checkPortLocked returns an error if another tunnel already uses the port. Caller must hold cm.mu.
func (cm *ConfigManager) checkPortLocked(id string, port int) error {
for _, existing := range cm.cfg.Tunnels {
@@ -350,6 +376,9 @@ func (cm *ConfigManager) AddTunnel(t protocol.TunnelConfig) (protocol.TunnelConf
if err := cm.checkPortLocked(t.ID, t.ListenPort); err != nil {
return t, err
}
if !cm.groupExistsLocked(t.GroupID) {
return t, fmt.Errorf("通道组不存在")
}
t.Enabled = true
now := time.Now()
@@ -409,6 +438,9 @@ func (cm *ConfigManager) PatchTunnel(id string, patch []byte) (protocol.TunnelCo
if err := cm.checkPortLocked(id, updated.ListenPort); err != nil {
return existing, existing.ListenPort, err
}
if !cm.groupExistsLocked(updated.GroupID) {
return existing, existing.ListenPort, fmt.Errorf("通道组不存在")
}
// 运行中修改时长:从本次开启时间重新计算到期时间
if updated.Enabled && updated.DurationMinutes != existing.DurationMinutes {
@@ -700,3 +732,221 @@ func (cm *ConfigManager) RecommendPort() int {
}
return 0
}
// ---- 通道组 ----
// FindGroup returns the group with the given ID
func (cm *ConfigManager) FindGroup(id string) (TunnelGroup, bool) {
cm.mu.RLock()
defer cm.mu.RUnlock()
for _, g := range cm.cfg.Groups {
if g.ID == id {
return g, true
}
}
return TunnelGroup{}, false
}
// GroupMembers returns the tunnels of a group ordered by listen port
func (cm *ConfigManager) GroupMembers(id string) []protocol.TunnelConfig {
cm.mu.RLock()
defer cm.mu.RUnlock()
return cm.groupMembersLocked(id)
}
func (cm *ConfigManager) groupMembersLocked(id string) []protocol.TunnelConfig {
var out []protocol.TunnelConfig
for _, t := range cm.cfg.Tunnels {
if id != "" && t.GroupID == id {
out = append(out, cloneTunnel(t))
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ListenPort < out[j].ListenPort })
return out
}
// GroupSpec describes a group and the members to add to it
type GroupSpec struct {
Name string
Owner string
// Targets 新建成员的目标(已展开的 主机:端口)
Targets []string
// Template 新建成员的通道设置:白名单、闲置关闭、最长开启时长、限速
Template protocol.TunnelConfig
// MoveIDs 加入该组的已有通道
MoveIDs []string
}
// newMembersLocked builds (but does not store) the tunnels for spec.Targets. Caller must hold cm.mu.
func (cm *ConfigManager) newMembersLocked(groupID string, spec GroupSpec, now time.Time) ([]protocol.TunnelConfig, error) {
if len(spec.Targets) == 0 {
return nil, nil
}
ports, err := cm.allocatePortsLocked(len(spec.Targets))
if err != nil {
return nil, err
}
names := memberNames(spec.Name, spec.Targets)
out := make([]protocol.TunnelConfig, 0, len(spec.Targets))
for i, target := range spec.Targets {
t := cloneTunnel(spec.Template)
t.ID = uuid.New().String()
t.Name, t.Target, t.ListenPort = names[i], target, ports[i]
t.GroupID = groupID
if strings.TrimSpace(t.Owner) == "" {
t.Owner = spec.Owner
}
if err := normalizeAndValidate(&t); err != nil {
return nil, fmt.Errorf("%s:%w", target, err)
}
t.AuthCode = generateAuthCode()
t.HistoricalBytesIn, t.HistoricalBytesOut, t.LastDurationSec = 0, 0, 0
t.CloseReason, t.FirstConnectedAt, t.ConnectedSec, t.LastConnectedSec = "", nil, 0, 0
t.DynamicWhitelist = nil
if t.IdleCloseMinutes == nil {
v := DefaultIdleCloseMinutes
t.IdleCloseMinutes = &v
}
t.Enabled = true
start := now
t.StartedAt = &start
t.ExpiresAt = nil
if t.DurationMinutes > 0 {
exp := now.Add(time.Duration(t.DurationMinutes) * time.Minute)
t.ExpiresAt = &exp
}
out = append(out, t)
}
return out, nil
}
// PreviewGroupMembers returns the tunnels that saving spec would create, without saving anything
func (cm *ConfigManager) PreviewGroupMembers(spec GroupSpec) ([]protocol.TunnelConfig, error) {
if strings.TrimSpace(spec.Name) == "" {
spec.Name = "通道组"
}
spec.Name = strings.TrimSpace(spec.Name)
cm.mu.RLock()
defer cm.mu.RUnlock()
return cm.newMembersLocked("", spec, time.Now())
}
// SaveGroup creates a group (id == "") or renames / adds members to an existing one.
// Returns the group, the tunnels it created and the existing members whose config changed
// (moved in, or the group was renamed) — the caller pushes both to the relay server.
func (cm *ConfigManager) SaveGroup(id string, spec GroupSpec) (TunnelGroup, []protocol.TunnelConfig, []protocol.TunnelConfig, error) {
spec.Name = strings.TrimSpace(spec.Name)
spec.Owner = strings.TrimSpace(spec.Owner)
if spec.Name == "" {
return TunnelGroup{}, nil, nil, fmt.Errorf("请填写通道组名称")
}
if len([]rune(spec.Name)) > 64 {
return TunnelGroup{}, nil, nil, fmt.Errorf("通道组名称不能超过 64 个字")
}
cm.mu.Lock()
defer cm.mu.Unlock()
gi := -1
for i, g := range cm.cfg.Groups {
if id != "" && g.ID == id {
gi = i
} else if g.Name == spec.Name {
return TunnelGroup{}, nil, nil, fmt.Errorf("已有同名的通道组「%s」", spec.Name)
}
}
if id != "" && gi < 0 {
return TunnelGroup{}, nil, nil, fmt.Errorf("通道组不存在")
}
if id == "" && len(spec.Targets) == 0 && len(spec.MoveIDs) == 0 {
return TunnelGroup{}, nil, nil, fmt.Errorf("请添加至少一个目标,或选择要加入的已有通道")
}
group := TunnelGroup{ID: id, Name: spec.Name, Owner: spec.Owner, CreatedAt: time.Now()}
if gi >= 0 {
group.CreatedAt = cm.cfg.Groups[gi].CreatedAt
} else {
group.ID = uuid.New().String()
}
move := make(map[string]bool)
for _, mid := range spec.MoveIDs {
found := false
for _, t := range cm.cfg.Tunnels {
if t.ID != mid {
continue
}
found = true
if t.GroupID != "" && t.GroupID != group.ID {
return TunnelGroup{}, nil, nil, fmt.Errorf("「%s」已属于其它通道组,请先将其移出", t.Name)
}
}
if !found {
return TunnelGroup{}, nil, nil, fmt.Errorf("通道 %s 未找到", mid)
}
move[mid] = true
}
created, err := cm.newMembersLocked(group.ID, spec, time.Now())
if err != nil {
return TunnelGroup{}, nil, nil, err
}
renamed := gi >= 0 && cm.cfg.Groups[gi].Name != group.Name
var changed []protocol.TunnelConfig
for i, t := range cm.cfg.Tunnels {
switch {
case move[t.ID] && t.GroupID != group.ID:
cm.cfg.Tunnels[i].GroupID = group.ID
changed = append(changed, cloneTunnel(cm.cfg.Tunnels[i]))
case renamed && t.GroupID == group.ID:
changed = append(changed, cloneTunnel(t))
}
}
cm.cfg.Tunnels = append(cm.cfg.Tunnels, created...)
if gi >= 0 {
cm.cfg.Groups[gi] = group
} else {
cm.cfg.Groups = append(cm.cfg.Groups, group)
}
out := make([]protocol.TunnelConfig, len(created))
for i, t := range created {
out[i] = cloneTunnel(t)
}
return group, out, changed, cm.save()
}
// RemoveGroup dissolves a group. With deleteMembers its tunnels are deleted too, otherwise they
// stay as standalone tunnels. Returns the affected members (as they are after the change).
func (cm *ConfigManager) RemoveGroup(id string, deleteMembers bool) ([]protocol.TunnelConfig, error) {
cm.mu.Lock()
defer cm.mu.Unlock()
groups := make([]TunnelGroup, 0, len(cm.cfg.Groups))
found := false
for _, g := range cm.cfg.Groups {
if g.ID == id {
found = true
continue
}
groups = append(groups, g)
}
if !found {
return nil, fmt.Errorf("通道组不存在")
}
var affected []protocol.TunnelConfig
kept := make([]protocol.TunnelConfig, 0, len(cm.cfg.Tunnels))
for _, t := range cm.cfg.Tunnels {
if t.GroupID != id {
kept = append(kept, t)
continue
}
t.GroupID = ""
affected = append(affected, cloneTunnel(t))
if !deleteMembers {
kept = append(kept, t)
}
}
cm.cfg.Groups = groups
cm.cfg.Tunnels = kept
return affected, cm.save()
}