Files
PortRelay/client/batch.go
T
DeepFal 5b355fb759 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 及组接口里的批量建通道
2026-10-13 20:55:57 +08:00

214 lines
6.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}