feat: 通道组并入「添加通道」表单,不再单独新建
- 去掉单独的「新建通道组」入口:添加通道表单的目标可以一次填多个
(10.0.1.11-13:22、10.0.1.11:8080-8082、逗号分隔),预览每个地址分到的连续端口
- 表单新增「通道组」选项:不分组 / 新建通道组 / 已有组;多个目标自动放进新建的组,
组名默认与通道名称相同;加入组时名称可不填,按「组名 (主机)」命名
- 闲置关闭、最长时长、限速、访问控制、责任人等设置对每个地址都生效,与单个通道一致
- 编辑通道时可以新建通道组并移入;组卡片上「添加地址」打开同一个表单并选好该组
- 编辑通道组只负责改名、责任人和加入已有通道
- 多个目标的连通性测试并发进行并汇总结果;网页表单补上责任人与限速
- 接口:POST /api/tunnels/batch(dry_run 预览、group_id / new_group),
PUT /api/tunnels/{id} 支持 new_group;移除 /api/groups/preview 及组接口里的批量建通道
This commit is contained in:
1 parent
e02c4c1184
commit
5b355fb759
13 files changed
+1078
-920
No files matched your search
+165
-105
@@ -364,33 +364,131 @@ func (cm *ConfigManager) AddTunnel(t protocol.TunnelConfig) (protocol.TunnelConf
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
t.ID = uuid.New().String()
|
||||
t.AuthCode = generateAuthCode()
|
||||
t.HistoricalBytesIn, t.HistoricalBytesOut, t.LastDurationSec = 0, 0, 0
|
||||
t.CloseReason, t.FirstConnectedAt, t.ConnectedSec, t.LastConnectedSec = "", nil, 0, 0
|
||||
if t.IdleCloseMinutes == nil {
|
||||
v := DefaultIdleCloseMinutes
|
||||
t.IdleCloseMinutes = &v
|
||||
}
|
||||
t.DynamicWhitelist = nil
|
||||
if err := cm.checkPortLocked(t.ID, t.ListenPort); err != nil {
|
||||
if err := cm.checkPortLocked("", t.ListenPort); err != nil {
|
||||
return t, err
|
||||
}
|
||||
if !cm.groupExistsLocked(t.GroupID) {
|
||||
return t, fmt.Errorf("通道组不存在")
|
||||
}
|
||||
prepareNew(&t, time.Now())
|
||||
cm.cfg.Tunnels = append(cm.cfg.Tunnels, t)
|
||||
return cloneTunnel(t), cm.save()
|
||||
}
|
||||
|
||||
// prepareNew resets the program-maintained fields of a tunnel being created and opens it
|
||||
func prepareNew(t *protocol.TunnelConfig, now time.Time) {
|
||||
t.ID = uuid.New().String()
|
||||
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
|
||||
now := time.Now()
|
||||
t.StartedAt = &now
|
||||
start := now
|
||||
t.StartedAt = &start
|
||||
t.ExpiresAt = nil
|
||||
if t.DurationMinutes > 0 {
|
||||
exp := now.Add(time.Duration(t.DurationMinutes) * time.Minute)
|
||||
t.ExpiresAt = &exp
|
||||
}
|
||||
}
|
||||
|
||||
cm.cfg.Tunnels = append(cm.cfg.Tunnels, t)
|
||||
return cloneTunnel(t), cm.save()
|
||||
// NewTunnels is one submission of the add-tunnel form
|
||||
type NewTunnels struct {
|
||||
// Template 通道设置。Target 可以写多个目标(见 ExpandTargets);ListenPort 只用于单个目标,
|
||||
// 多个目标自动分配一段连续端口;GroupID 表示加入已有通道组。
|
||||
Template protocol.TunnelConfig
|
||||
// NewGroup 新建一个通道组并把这些通道放进去
|
||||
NewGroup string
|
||||
}
|
||||
|
||||
// CreateTunnels adds the tunnels of one add-tunnel form. Several targets become one tunnel each,
|
||||
// named "名称 (主机)", and must go into a group; a group member without a name is named after the
|
||||
// group. With dryRun nothing is saved and the result is a preview.
|
||||
// Returns the group the tunnels joined (nil when ungrouped) and the created tunnels.
|
||||
func (cm *ConfigManager) CreateTunnels(req NewTunnels, dryRun bool) (*TunnelGroup, []protocol.TunnelConfig, error) {
|
||||
tpl := req.Template
|
||||
targets, err := ExpandTargets(tpl.Target, "")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
newGroup := strings.TrimSpace(req.NewGroup)
|
||||
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
var group *TunnelGroup
|
||||
switch {
|
||||
case newGroup != "":
|
||||
if err := cm.checkGroupNameLocked("", newGroup); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
group = &TunnelGroup{ID: uuid.New().String(), Name: newGroup, Owner: strings.TrimSpace(tpl.Owner), CreatedAt: time.Now()}
|
||||
case tpl.GroupID != "":
|
||||
g, ok := cm.findGroupLocked(tpl.GroupID)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("通道组不存在")
|
||||
}
|
||||
group = &g
|
||||
case len(targets) > 1 && !dryRun:
|
||||
return nil, nil, fmt.Errorf("多个目标需要放进同一个通道组,请选择或新建通道组")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(tpl.Name)
|
||||
base := name
|
||||
if base == "" && group != nil {
|
||||
base = group.Name
|
||||
}
|
||||
if base == "" && dryRun {
|
||||
base = "通道"
|
||||
}
|
||||
names := []string{base}
|
||||
if len(targets) > 1 || (name == "" && group != nil) {
|
||||
names = memberNames(base, targets)
|
||||
}
|
||||
|
||||
var ports []int
|
||||
if len(targets) == 1 && tpl.ListenPort > 0 {
|
||||
if err := cm.checkPortLocked("", tpl.ListenPort); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ports = []int{tpl.ListenPort}
|
||||
} else if ports, err = cm.allocatePortsLocked(len(targets)); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
created := make([]protocol.TunnelConfig, 0, len(targets))
|
||||
for i, target := range targets {
|
||||
t := cloneTunnel(tpl)
|
||||
t.Name, t.Target, t.ListenPort, t.GroupID = names[i], target, ports[i], ""
|
||||
if group != nil {
|
||||
t.GroupID = group.ID
|
||||
}
|
||||
if err := normalizeAndValidate(&t); err != nil {
|
||||
if len(targets) > 1 {
|
||||
err = fmt.Errorf("%s:%w", target, err)
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
prepareNew(&t, now)
|
||||
created = append(created, t)
|
||||
}
|
||||
if dryRun {
|
||||
return group, created, nil
|
||||
}
|
||||
if newGroup != "" {
|
||||
cm.cfg.Groups = append(cm.cfg.Groups, *group)
|
||||
}
|
||||
cm.cfg.Tunnels = append(cm.cfg.Tunnels, created...)
|
||||
out := make([]protocol.TunnelConfig, len(created))
|
||||
for i, t := range created {
|
||||
out[i] = cloneTunnel(t)
|
||||
}
|
||||
return group, out, cm.save()
|
||||
}
|
||||
|
||||
// PatchTunnel applies a partial JSON update: only fields present in patch change.
|
||||
@@ -416,6 +514,11 @@ func (cm *ConfigManager) PatchTunnel(id string, patch []byte) (protocol.TunnelCo
|
||||
if err := json.Unmarshal(patch, &updated); err != nil {
|
||||
return existing, existing.ListenPort, fmt.Errorf("Invalid JSON")
|
||||
}
|
||||
// new_group:新建一个通道组并把该通道移进去
|
||||
var extra struct {
|
||||
NewGroup string `json:"new_group"`
|
||||
}
|
||||
_ = json.Unmarshal(patch, &extra)
|
||||
|
||||
// 受保护字段
|
||||
updated.ID = existing.ID
|
||||
@@ -438,7 +541,14 @@ 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) {
|
||||
var newGroup *TunnelGroup
|
||||
if name := strings.TrimSpace(extra.NewGroup); name != "" {
|
||||
if err := cm.checkGroupNameLocked("", name); err != nil {
|
||||
return existing, existing.ListenPort, err
|
||||
}
|
||||
newGroup = &TunnelGroup{ID: uuid.New().String(), Name: name, Owner: updated.Owner, CreatedAt: time.Now()}
|
||||
updated.GroupID = newGroup.ID
|
||||
} else if !cm.groupExistsLocked(updated.GroupID) {
|
||||
return existing, existing.ListenPort, fmt.Errorf("通道组不存在")
|
||||
}
|
||||
|
||||
@@ -455,6 +565,9 @@ func (cm *ConfigManager) PatchTunnel(id string, patch []byte) (protocol.TunnelCo
|
||||
}
|
||||
}
|
||||
|
||||
if newGroup != nil {
|
||||
cm.cfg.Groups = append(cm.cfg.Groups, *newGroup)
|
||||
}
|
||||
cm.cfg.Tunnels[idx] = updated
|
||||
return cloneTunnel(updated), existing.ListenPort, cm.save()
|
||||
}
|
||||
@@ -739,6 +852,10 @@ func (cm *ConfigManager) RecommendPort() int {
|
||||
func (cm *ConfigManager) FindGroup(id string) (TunnelGroup, bool) {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return cm.findGroupLocked(id)
|
||||
}
|
||||
|
||||
func (cm *ConfigManager) findGroupLocked(id string) (TunnelGroup, bool) {
|
||||
for _, g := range cm.cfg.Groups {
|
||||
if g.ID == id {
|
||||
return g, true
|
||||
@@ -747,6 +864,22 @@ func (cm *ConfigManager) FindGroup(id string) (TunnelGroup, bool) {
|
||||
return TunnelGroup{}, false
|
||||
}
|
||||
|
||||
// checkGroupNameLocked validates the name for group id ("" for a new group). Caller must hold cm.mu.
|
||||
func (cm *ConfigManager) checkGroupNameLocked(id, name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("请填写通道组名称")
|
||||
}
|
||||
if len([]rune(name)) > 64 {
|
||||
return fmt.Errorf("通道组名称不能超过 64 个字")
|
||||
}
|
||||
for _, g := range cm.cfg.Groups {
|
||||
if g.ID != id && g.Name == name {
|
||||
return fmt.Errorf("已有同名的通道组「%s」", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GroupMembers returns the tunnels of a group ordered by listen port
|
||||
func (cm *ConfigManager) GroupMembers(id string) []protocol.TunnelConfig {
|
||||
cm.mu.RLock()
|
||||
@@ -765,101 +898,38 @@ func (cm *ConfigManager) groupMembersLocked(id string) []protocol.TunnelConfig {
|
||||
return out
|
||||
}
|
||||
|
||||
// GroupSpec describes a group and the members to add to it
|
||||
// GroupSpec describes a group's name and owner, and existing tunnels to move into it.
|
||||
// 新通道统一在添加通道表单里创建(见 CreateTunnels)。
|
||||
type GroupSpec struct {
|
||||
Name string
|
||||
Owner string
|
||||
// Targets 新建成员的目标(已展开的 主机:端口)
|
||||
Targets []string
|
||||
// Template 新建成员的通道设置:白名单、闲置关闭、最长开启时长、限速
|
||||
Template protocol.TunnelConfig
|
||||
// MoveIDs 加入该组的已有通道
|
||||
Name string
|
||||
Owner string
|
||||
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) {
|
||||
// SaveGroup creates a group from existing tunnels (id == "") or renames / adds members to one.
|
||||
// Returns the group and the members whose config changed (moved in, or the group was renamed)
|
||||
// — the caller pushes them to the relay server.
|
||||
func (cm *ConfigManager) SaveGroup(id string, spec GroupSpec) (TunnelGroup, []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()
|
||||
|
||||
if err := cm.checkGroupNameLocked(id, spec.Name); err != nil {
|
||||
return TunnelGroup{}, nil, err
|
||||
}
|
||||
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("通道组不存在")
|
||||
return TunnelGroup{}, nil, fmt.Errorf("通道组不存在")
|
||||
}
|
||||
if id == "" && len(spec.Targets) == 0 && len(spec.MoveIDs) == 0 {
|
||||
return TunnelGroup{}, nil, nil, fmt.Errorf("请添加至少一个目标,或选择要加入的已有通道")
|
||||
if id == "" && len(spec.MoveIDs) == 0 {
|
||||
return TunnelGroup{}, nil, fmt.Errorf("请选择要放进该组的通道")
|
||||
}
|
||||
|
||||
group := TunnelGroup{ID: id, Name: spec.Name, Owner: spec.Owner, CreatedAt: time.Now()}
|
||||
@@ -878,20 +948,15 @@ func (cm *ConfigManager) SaveGroup(id string, spec GroupSpec) (TunnelGroup, []pr
|
||||
}
|
||||
found = true
|
||||
if t.GroupID != "" && t.GroupID != group.ID {
|
||||
return TunnelGroup{}, nil, nil, fmt.Errorf("「%s」已属于其它通道组,请先将其移出", t.Name)
|
||||
return TunnelGroup{}, nil, fmt.Errorf("「%s」已属于其它通道组,请先将其移出", t.Name)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return TunnelGroup{}, nil, nil, fmt.Errorf("通道 %s 未找到", mid)
|
||||
return TunnelGroup{}, 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 {
|
||||
@@ -903,17 +968,12 @@ func (cm *ConfigManager) SaveGroup(id string, spec GroupSpec) (TunnelGroup, []pr
|
||||
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()
|
||||
return group, changed, cm.save()
|
||||
}
|
||||
|
||||
// RemoveGroup dissolves a group. With deleteMembers its tunnels are deleted too, otherwise they
|
||||
|
||||
Reference in new issue
Block a user