- 去掉单独的「新建通道组」入口:添加通道表单的目标可以一次填多个
(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 及组接口里的批量建通道
214 lines
6.1 KiB
Go
214 lines
6.1 KiB
Go
package client
|
||
|
||
import (
|
||
"fmt"
|
||
"net"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// maxBatchTargets 一次批量创建的目标上限,避免误操作占满端口池
|
||
const maxBatchTargets = 64
|
||
|
||
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_])?$`)
|
||
)
|
||
|
||
func splitList(s string) []string {
|
||
var out []string
|
||
for _, item := range listSeparators.Split(strings.TrimSpace(s), -1) {
|
||
if item != "" {
|
||
out = append(out, item)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// parsePorts accepts "22, 8080-8082"
|
||
func parsePorts(s string) ([]int, error) {
|
||
var out []int
|
||
for _, item := range splitList(s) {
|
||
lo, hi := item, item
|
||
if i := strings.Index(item, "-"); i > 0 {
|
||
lo, hi = item[:i], item[i+1:]
|
||
}
|
||
a, err1 := strconv.Atoi(lo)
|
||
b, err2 := strconv.Atoi(hi)
|
||
if err1 != nil || err2 != nil || a <= 0 || b > 65535 || a > b {
|
||
return nil, fmt.Errorf("无效的端口 %q", item)
|
||
}
|
||
if b-a >= maxBatchTargets {
|
||
return nil, fmt.Errorf("端口范围 %q 过大", item)
|
||
}
|
||
for p := a; p <= b; p++ {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// expandIPv4Range expands "10.0.1.11-13" or "10.0.1.11-10.0.1.13"
|
||
func expandIPv4Range(item string) ([]string, bool, error) {
|
||
var from, to net.IP
|
||
if m := shortRange.FindStringSubmatch(item); m != nil {
|
||
from, to = net.ParseIP(m[1]+m[2]).To4(), net.ParseIP(m[1]+m[3]).To4()
|
||
} else if m := fullRange.FindStringSubmatch(item); m != nil {
|
||
from, to = net.ParseIP(m[1]).To4(), net.ParseIP(m[2]).To4()
|
||
} else {
|
||
return nil, false, nil
|
||
}
|
||
if from == nil || to == nil {
|
||
return nil, true, fmt.Errorf("无效的地址范围 %q", item)
|
||
}
|
||
a := uint32(from[0])<<24 | uint32(from[1])<<16 | uint32(from[2])<<8 | uint32(from[3])
|
||
b := uint32(to[0])<<24 | uint32(to[1])<<16 | uint32(to[2])<<8 | uint32(to[3])
|
||
if a > b {
|
||
return nil, true, fmt.Errorf("地址范围 %q 起止颠倒", item)
|
||
}
|
||
if b-a >= maxBatchTargets {
|
||
return nil, true, fmt.Errorf("地址范围 %q 过大", item)
|
||
}
|
||
var out []string
|
||
for v := a; v <= b; v++ {
|
||
out = append(out, net.IPv4(byte(v>>24), byte(v>>16), byte(v>>8), byte(v)).String())
|
||
}
|
||
return out, true, nil
|
||
}
|
||
|
||
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"),也可以直接写成 "主机:端口"(主机和端口都可以是范围,如
|
||
// "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 {
|
||
return nil, err
|
||
}
|
||
seen := make(map[string]bool)
|
||
var out []string
|
||
add := func(t string) error {
|
||
if !seen[t] {
|
||
seen[t] = true
|
||
out = append(out, t)
|
||
}
|
||
if len(out) > maxBatchTargets {
|
||
return fmt.Errorf("一次最多添加 %d 个目标", maxBatchTargets)
|
||
}
|
||
return nil
|
||
}
|
||
items := splitList(hosts)
|
||
if len(items) == 0 {
|
||
return nil, fmt.Errorf("请填写至少一个目标主机")
|
||
}
|
||
for _, item := range items {
|
||
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 {
|
||
if itemPorts, err = parsePorts(p); err != nil || len(itemPorts) == 0 {
|
||
return nil, fmt.Errorf("无效的目标 %q", item)
|
||
}
|
||
itemHosts = h
|
||
}
|
||
hostList, err := hostsOf(itemHosts)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(itemPorts) == 0 {
|
||
return nil, fmt.Errorf("%q 缺少端口,请写成 主机:端口", item)
|
||
}
|
||
for _, h := range hostList {
|
||
for _, p := range itemPorts {
|
||
if err := add(net.JoinHostPort(h, strconv.Itoa(p))); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// memberNames names batch-created tunnels after the group: "组名 (主机)" when every target
|
||
// shares one port, otherwise "组名 (主机:端口)".
|
||
func memberNames(group string, targets []string) []string {
|
||
ports := make(map[string]bool)
|
||
for _, t := range targets {
|
||
if _, p, err := net.SplitHostPort(t); err == nil {
|
||
ports[p] = true
|
||
}
|
||
}
|
||
names := make([]string, len(targets))
|
||
for i, t := range targets {
|
||
label := t
|
||
if len(ports) == 1 {
|
||
if h, _, err := net.SplitHostPort(t); err == nil {
|
||
label = h
|
||
}
|
||
}
|
||
names[i] = fmt.Sprintf("%s (%s)", group, label)
|
||
}
|
||
return names
|
||
}
|
||
|
||
// allocatePortsLocked picks n free listen ports, preferring one contiguous block so the mapping
|
||
// is easy to read. Caller must hold cm.mu.
|
||
func (cm *ConfigManager) allocatePortsLocked(n int) ([]int, error) {
|
||
used := make(map[int]bool)
|
||
for _, t := range cm.cfg.Tunnels {
|
||
used[t.ListenPort] = true
|
||
}
|
||
// 与单个通道的推荐顺序一致:先 33660 往上,再从端口池开头补
|
||
var order []int
|
||
for p := 33660; p <= PortMax; p++ {
|
||
order = append(order, p)
|
||
}
|
||
for p := PortMin; p < 33660; p++ {
|
||
order = append(order, p)
|
||
}
|
||
free := func(p int) bool { return p != ControlPort && !used[p] }
|
||
|
||
for i := range order {
|
||
ok := true
|
||
for k := 0; k < n; k++ {
|
||
if i+k >= len(order) || !free(order[i+k]) || (k > 0 && order[i+k] != order[i+k-1]+1) {
|
||
ok = false
|
||
break
|
||
}
|
||
}
|
||
if ok {
|
||
return order[i : i+n], nil
|
||
}
|
||
}
|
||
var out []int
|
||
for _, p := range order {
|
||
if free(p) {
|
||
out = append(out, p)
|
||
if len(out) == n {
|
||
return out, nil
|
||
}
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("端口池剩余 %d 个空闲端口,不够分配 %d 个", len(out), n)
|
||
}
|