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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user