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:
DeepFal committed 2026-10-13 20:55:57 +08:00
1 parent e02c4c1184
commit 5b355fb759
13 files changed
+1078 -920

No files matched your search

+4
View File
@@ -209,6 +209,10 @@ func (s *APIServer) handleTunnelItem(w http.ResponseWriter, r *http.Request) {
}
tunnelID := parts[2]
if tunnelID == "batch" && len(parts) == 3 && r.Method == http.MethodPost {
s.handleTunnelBatch(w, r)
return
}
if len(parts) == 4 && parts[3] == "toggle" && r.Method == http.MethodPost {
s.handleToggle(w, r, tunnelID)
return
+37 -84
View File
@@ -46,42 +46,16 @@ func (s *APIServer) groupsView(metrics protocol.MetricsMessage) []GroupView {
return out
}
// groupRequest creates a group or adds members to one
// groupRequest renames a group or moves existing tunnels into it
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 (req groupRequest) spec() GroupSpec {
return GroupSpec{Name: req.Name, Owner: req.Owner, MoveIDs: req.TunnelIDs}
}
func decodeGroupRequest(r *http.Request) (groupRequest, error) {
@@ -93,11 +67,8 @@ func decodeGroupRequest(r *http.Request) (groupRequest, error) {
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)
}
// syncChanged pushes members whose group changed to the relay server
func (s *APIServer) syncChanged(changed []protocol.TunnelConfig) {
for _, t := range changed {
if t.Enabled {
s.agent.SyncTunnel(t, true)
@@ -105,7 +76,7 @@ func (s *APIServer) syncGroupChanges(created, changed []protocol.TunnelConfig) {
}
}
// handleGroups: GET 列表;POST 新建通道组(批量新建目标 + / 或加入已有通道)
// handleGroups: GET 列表;POST 用已有通道组成通道组(新通道在添加通道表单里创建,见 handleTunnelBatch)
func (s *APIServer) handleGroups(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@@ -116,18 +87,13 @@ func (s *APIServer) handleGroups(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
spec, err := req.spec()
g, changed, err := s.cm.SaveGroup("", 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)})
s.syncChanged(changed)
jsonResponse(w, http.StatusCreated, map[string]any{"group": g})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
@@ -135,8 +101,7 @@ func (s *APIServer) handleGroups(w http.ResponseWriter, r *http.Request) {
// handleGroupItem:
//
// POST /api/groups/preview 预览批量展开结果(不保存)
// PUT /api/groups/{id} 改名 / 责任人,并可追加成员
// PUT /api/groups/{id} 改名 / 责任人,并可把已有通道加入该组
// DELETE /api/groups/{id}?delete_members=1 解散(默认保留成员为独立通道)
// POST /api/groups/{id}/toggle {"enabled": bool} 整组开关
// ... /api/groups/{id}/leases|invites|requests 与单个通道相同的授权接口
@@ -146,10 +111,6 @@ func (s *APIServer) handleGroupItem(w http.ResponseWriter, r *http.Request) {
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 {
@@ -203,18 +164,13 @@ func (s *APIServer) handleGroupItem(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(req.Name) == "" {
req.Name = g.Name
}
spec, err := req.spec()
updated, changed, err := s.cm.SaveGroup(id, 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)})
s.syncChanged(changed)
jsonResponse(w, http.StatusOK, map[string]any{"group": updated})
case http.MethodDelete:
deleteMembers := r.URL.Query().Get("delete_members") == "1"
@@ -240,38 +196,35 @@ func (s *APIServer) handleGroupItem(w http.ResponseWriter, r *http.Request) {
}
}
// 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")
// handleTunnelBatch: POST /api/tunnels/batch —— 添加通道表单的提交,可以一次创建单个或多个通道。
// 请求体是通道设置,另加:
//
// target 可以写多个目标,如 "10.0.1.11-13:22, 10.0.2.5:3306"(见 ExpandTargets)
// group_id 加入已有通道组;new_group 新建通道组。多个目标必须放进通道组
// dry_run 只预览将创建的通道(名称、目标、分配的端口),不保存
//
// 返回 {"group": 加入的通道组或 null, "tunnels": [...]}
func (s *APIServer) handleTunnelBatch(w http.ResponseWriter, r *http.Request) {
var req struct {
protocol.TunnelConfig
NewGroup string `json:"new_group"`
DryRun bool `json:"dry_run"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON payload")
return
}
spec, err := req.spec()
group, created, err := s.cm.CreateTunnels(NewTunnels{Template: req.TunnelConfig, NewGroup: req.NewGroup}, req.DryRun)
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
status := http.StatusOK
if !req.DryRun {
status = http.StatusCreated
for _, t := range created {
s.agent.SyncTunnel(t, false)
}
}
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
jsonResponse(w, status, map[string]any{"group": group, "tunnels": created})
}
+25 -21
View File
@@ -15,7 +15,7 @@ var (
listSeparators = regexp.MustCompile(`[\s,,、;;]+`)
shortRange = regexp.MustCompile(`^(\d{1,3}\.\d{1,3}\.\d{1,3}\.)(\d{1,3})-(\d{1,3})$`)
fullRange = regexp.MustCompile(`^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})-(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$`)
hostnameRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$`)
hostnameRe = regexp.MustCompile(`^[A-Za-z0-9_]([A-Za-z0-9._-]*[A-Za-z0-9_])?$`)
)
func splitList(s string) []string {
@@ -83,11 +83,24 @@ func validHost(h string) bool {
return net.ParseIP(h) != nil || hostnameRe.MatchString(h)
}
// hostsOf expands one host item: an IPv4 range ("10.0.1.11-13") or a single IP / hostname
func hostsOf(item string) ([]string, error) {
hosts, isRange, err := expandIPv4Range(item)
if err != nil || isRange {
return hosts, err
}
if !validHost(item) {
return nil, fmt.Errorf("无效的主机 %q", item)
}
return []string{item}, nil
}
// ExpandTargets turns host and port lists into "host:port" targets, host by host.
//
// hosts 用逗号、空格或换行分隔,每一项可以是 IP、主机名、IPv4 范围("10.0.1.11-13" 或
// "10.0.1.11-10.0.1.13"),也可以直接写成 "主机:端口";ports 形如 "22, 8080-8082",
// 作用于没有写端口的主机。结果去重并保持输入顺序。
// "10.0.1.11-10.0.1.13"),也可以直接写成 "主机:端口"(主机和端口都可以是范围,如
// "10.0.1.11-13:22"、"10.0.1.11:8080-8082");ports 形如 "22, 8080-8082",作用于没有写端口
// 的主机。结果去重并保持输入顺序。
func ExpandTargets(hosts, ports string) ([]string, error) {
portList, err := parsePorts(ports)
if err != nil {
@@ -110,32 +123,23 @@ func ExpandTargets(hosts, ports string) ([]string, error) {
return nil, fmt.Errorf("请填写至少一个目标主机")
}
for _, item := range items {
// 自带端口的完整目标:"10.0.1.11:22"、"[fd00::1]:22"
itemHosts, itemPorts := item, portList
// 自带端口的完整目标:"10.0.1.11:22"、"10.0.1.11-13:22"、"[fd00::1]:22"
if h, p, err := net.SplitHostPort(item); err == nil {
n, perr := strconv.Atoi(p)
if !validHost(h) || perr != nil || n <= 0 || n > 65535 {
if itemPorts, err = parsePorts(p); err != nil || len(itemPorts) == 0 {
return nil, fmt.Errorf("无效的目标 %q", item)
}
if err := add(net.JoinHostPort(h, p)); err != nil {
return nil, err
}
continue
itemHosts = h
}
hostsOfItem, isRange, err := expandIPv4Range(item)
hostList, err := hostsOf(itemHosts)
if err != nil {
return nil, err
}
if !isRange {
if !validHost(item) {
return nil, fmt.Errorf("无效的主机 %q", item)
}
hostsOfItem = []string{item}
if len(itemPorts) == 0 {
return nil, fmt.Errorf("%q 缺少端口,请写成 主机:端口", item)
}
if len(portList) == 0 {
return nil, fmt.Errorf("请填写端口,或把目标写成 主机:端口")
}
for _, h := range hostsOfItem {
for _, p := range portList {
for _, h := range hostList {
for _, p := range itemPorts {
if err := add(net.JoinHostPort(h, strconv.Itoa(p))); err != nil {
return nil, err
}
+165 -105
View File
@@ -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
+82 -36
View File
@@ -26,6 +26,8 @@ func TestExpandTargets(t *testing.T) {
{"10.0.1.1,10.0.1.2、10.0.1.1", "22", []string{"10.0.1.1:22", "10.0.1.2:22"}},
{"fd00::1", "22", []string{"[fd00::1]:22"}},
{"10.0.2.9:5432", "", []string{"10.0.2.9:5432"}},
{"10.0.1.11-13:22", "", []string{"10.0.1.11:22", "10.0.1.12:22", "10.0.1.13:22"}},
{"10.0.1.11:8080-8081, db_1.internal:3306", "", []string{"10.0.1.11:8080", "10.0.1.11:8081", "db_1.internal:3306"}},
}
for _, c := range cases {
got, err := ExpandTargets(c.hosts, c.ports)
@@ -42,6 +44,9 @@ func TestExpandTargets(t *testing.T) {
{"10.0.1.11", "0"},
{"10.0.1.11", "70000"},
{"10.0.1.1-40", "22, 23"},
{"10.0.1.11-13", ""},
{"10.0.1.11-300:22", ""},
{"10.0.1.11:0", ""},
} {
if got, err := ExpandTargets(bad[0], bad[1]); err == nil {
t.Errorf("ExpandTargets(%q, %q) should fail, got %v", bad[0], bad[1], got)
@@ -77,9 +82,25 @@ func TestAllocatePortsPrefersContiguousBlock(t *testing.T) {
}
}
type groupResp struct {
Group TunnelGroup `json:"group"`
Created []protocol.TunnelConfig `json:"created"`
type batchResp struct {
Group *TunnelGroup `json:"group"`
Tunnels []protocol.TunnelConfig `json:"tunnels"`
}
// addTunnels submits the add-tunnel form
func (e *env) addTunnels(t *testing.T, body map[string]any) batchResp {
t.Helper()
status, raw := e.call(t, "POST", "/api/tunnels/batch", body)
want := http.StatusCreated
if body["dry_run"] == true {
want = http.StatusOK
}
if status != want {
t.Fatalf("add tunnels %v: %d %s", body, status, raw)
}
var out batchResp
_ = json.Unmarshal(raw, &out)
return out
}
func (e *env) group(t *testing.T, id string) GroupView {
@@ -113,20 +134,25 @@ func TestGroupEndToEnd(t *testing.T) {
waitFor(t, "agent connected", e.agent.IsConnected)
echo1, echo2 := startEcho(t), startEcho(t)
status, body := e.call(t, "POST", "/api/groups", map[string]any{"name": "订单服务", "owner": "运维组", "hosts": echo1 + ", " + echo2})
if status != http.StatusCreated {
t.Fatalf("create group: %d %s", status, body)
g := e.addTunnels(t, map[string]any{
"name": "订单服务", "owner": "运维组", "target": echo1 + ", " + echo2, "new_group": "订单服务",
"whitelist": []string{}, "idle_close_minutes": 60, "duration_minutes": 480, "max_bandwidth_mb": 5,
})
if g.Group == nil || g.Group.Name != "订单服务" || g.Group.Owner != "运维组" {
t.Fatalf("new group: %+v", g.Group)
}
var g groupResp
_ = json.Unmarshal(body, &g)
if len(g.Created) != 2 || g.Created[1].ListenPort != g.Created[0].ListenPort+1 {
t.Fatalf("members should get a contiguous port block: %+v", g.Created)
if len(g.Tunnels) != 2 || g.Tunnels[1].ListenPort != g.Tunnels[0].ListenPort+1 {
t.Fatalf("members should get a contiguous port block: %+v", g.Tunnels)
}
m1, m2 := g.Created[0], g.Created[1]
for _, m := range g.Created {
m1, m2 := g.Tunnels[0], g.Tunnels[1]
for _, m := range g.Tunnels {
if m.GroupID != g.Group.ID || m.Owner != "运维组" || len(m.Whitelist) != 0 || !strings.HasPrefix(m.Name, "订单服务 (") {
t.Fatalf("member not set up as a link-only group member: %+v", m)
}
// 表单里的每一项设置都用于每个成员
if *m.IdleCloseMinutes != 60 || m.DurationMinutes != 480 || m.MaxBandwidthMB != 5 || m.ExpiresAt == nil {
t.Fatalf("member lost form settings: %+v", m)
}
}
waitFor(t, "members online", func() bool {
return e.tunnel(t, m1.ID).Metrics.Status == "online" && e.tunnel(t, m2.ID).Metrics.Status == "online"
@@ -135,7 +161,7 @@ func TestGroupEndToEnd(t *testing.T) {
t.Fatal("group members are link-only by default")
}
status, body = e.call(t, "POST", "/api/groups/"+g.Group.ID+"/invites", map[string]any{"note": "小王", "lease_duration": "8h"})
status, body := e.call(t, "POST", "/api/groups/"+g.Group.ID+"/invites", map[string]any{"note": "小王", "lease_duration": "8h"})
if status != http.StatusCreated {
t.Fatalf("group invite: %d %s", status, body)
}
@@ -155,7 +181,7 @@ func TestGroupEndToEnd(t *testing.T) {
}
grantViaInvite(t, e, inv.Invite.Code, "小王")
for _, m := range g.Created {
for _, m := range g.Tunnels {
if got, err := echoVia(m.ListenPort, "hi"); err != nil || got != "hi" {
t.Fatalf("member %d not reachable after group grant: %q %v", m.ListenPort, got, err)
}
@@ -213,7 +239,7 @@ func TestGroupFromExistingTunnelsRenameAndMoveOut(t *testing.T) {
if status != http.StatusCreated {
t.Fatalf("group from existing tunnels: %d %s", status, body)
}
var g groupResp
var g batchResp
_ = json.Unmarshal(body, &g)
if e.tunnel(t, ids[0]).GroupID != g.Group.ID || e.tunnel(t, ids[1]).GroupID != g.Group.ID {
t.Fatal("existing tunnels should join the group")
@@ -271,38 +297,58 @@ func TestGroupFromExistingTunnelsRenameAndMoveOut(t *testing.T) {
}
}
func TestGroupPreviewDoesNotSave(t *testing.T) {
func TestAddTunnelFormPreviewAndRules(t *testing.T) {
e := startEnv(t, testToken)
status, body := e.call(t, "POST", "/api/groups/preview", map[string]any{"name": "订单服务", "hosts": "10.0.1.11-12", "ports": "22"})
if status != http.StatusOK {
t.Fatalf("preview: %d %s", status, body)
}
var out struct {
Members []struct {
Name string `json:"name"`
Target string `json:"target"`
ListenPort int `json:"listen_port"`
} `json:"members"`
}
_ = json.Unmarshal(body, &out)
if len(out.Members) != 2 || out.Members[0].Name != "订单服务 (10.0.1.11)" || out.Members[1].ListenPort != out.Members[0].ListenPort+1 {
t.Fatalf("preview: %+v", out.Members)
// 预览:不保存,展开目标并预分配连续端口
pv := e.addTunnels(t, map[string]any{"target": "10.0.1.11-12:22", "dry_run": true})
if len(pv.Tunnels) != 2 || pv.Tunnels[0].Target != "10.0.1.11:22" || pv.Tunnels[1].ListenPort != pv.Tunnels[0].ListenPort+1 {
t.Fatalf("preview: %+v", pv.Tunnels)
}
if len(e.cm.Get().Tunnels) != 0 {
t.Fatal("preview must not create tunnels")
}
if status, _ := e.call(t, "POST", "/api/groups/preview", map[string]any{"hosts": "10.0.1.11"}); status != http.StatusBadRequest {
t.Fatal("missing ports should be rejected")
// 多个目标必须放进通道组
if status, _ := e.call(t, "POST", "/api/tunnels/batch", map[string]any{"name": "x", "target": "10.0.1.11-12:22"}); status != http.StatusBadRequest {
t.Fatal("several targets without a group must be rejected")
}
// 单个目标:名称和端口按填写的来,不分组
port := freeRelayPort(t)
one := e.addTunnels(t, map[string]any{"name": "MySQL", "target": "10.0.2.9:3306", "listen_port": port})
if one.Group != nil || len(one.Tunnels) != 1 || one.Tunnels[0].Name != "MySQL" || one.Tunnels[0].ListenPort != port {
t.Fatalf("single tunnel: %+v", one)
}
// 加入已有组且不填名称:按组名命名
grp := e.addTunnels(t, map[string]any{"name": "订单服务", "target": "10.0.1.11:22", "new_group": "订单服务"})
if grp.Tunnels[0].Name != "订单服务" {
t.Fatalf("a named single tunnel keeps its name: %+v", grp.Tunnels[0])
}
more := e.addTunnels(t, map[string]any{"target": "10.0.1.12-13:22", "group_id": grp.Group.ID})
if more.Group.ID != grp.Group.ID || more.Tunnels[0].Name != "订单服务 (10.0.1.12)" || more.Tunnels[0].GroupID != grp.Group.ID {
t.Fatalf("members added to an existing group: %+v", more)
}
if status, _ := e.call(t, "POST", "/api/tunnels/batch", map[string]any{"name": "y", "target": "10.0.3.1:22", "new_group": "订单服务"}); status != http.StatusBadRequest {
t.Fatal("duplicate group names must be rejected")
}
// 编辑通道时新建通道组
status, body := e.call(t, "PUT", "/api/tunnels/"+one.Tunnels[0].ID, map[string]any{"new_group": "数据库"})
var moved protocol.TunnelConfig
_ = json.Unmarshal(body, &moved)
groups := e.cm.Get().Groups
if status != http.StatusOK || len(groups) != 2 || groups[1].Name != "数据库" || moved.GroupID != groups[1].ID {
t.Fatalf("edit into a new group: %d %s %+v", status, body, groups)
}
}
func TestGroupIdleCloseIsLinked(t *testing.T) {
e := startEnvWith(t, testToken, func(a *Agent) { a.idleUnit = 3 * time.Second })
waitFor(t, "agent connected", e.agent.IsConnected)
_, body := e.call(t, "POST", "/api/groups", map[string]any{"name": "g", "hosts": "127.0.0.1", "ports": "1, 2", "idle_close_minutes": 1})
var g groupResp
_ = json.Unmarshal(body, &g)
a, b := g.Created[0], g.Created[1]
g := e.addTunnels(t, map[string]any{"name": "g", "target": "127.0.0.1:1, 127.0.0.1:2", "new_group": "g", "idle_close_minutes": 1})
a, b := g.Tunnels[0], g.Tunnels[1]
waitFor(t, "members online", func() bool {
return e.tunnel(t, a.ID).Metrics.Status == "online" && e.tunnel(t, b.ID).Metrics.Status == "online"
})