Files
PortRelay/client/batch.go
T
DeepFal 3797bef88a feat: 通道组——一次开放多台主机 / 多个端口,共用邀请与授权
- 批量创建:主机支持 IP、主机名、末段范围(10.0.1.11-13)和 主机:端口,
  与端口列表展开为 主机 × 端口,自动分配连续的访问端口,创建前可预览
- 授权以组为单位:一条邀请链接、一次审批即可访问组内全部地址;
  计时从首次连上任一地址开始,撤销一次全组断开;后加入组的通道自动获得组授权
- 网关授权状态按作用范围(通道 / 通道组)存储,通道的动态白名单统一由状态库重新计算,
  通道授权与组授权互不干扰;组内最后一个通道关闭或组被解散时,组的邀请与授权失效
- 闲置联动:组内任一地址在用时整组都不算闲置;支持整组开关
- 已有通道可并入组或移出组;组可改名、追加目标、解散(保留或删除成员)
- App 与网页面板:组卡片、新建 / 编辑通道组、组邀请与“连接与授权”,授权页列出全部地址
- 测试:网关 9 个、Agent 5 个通道组用例
2026-10-13 20:16:43 +08:00

210 lines
5.8 KiB
Go
Raw 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)
}
// 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",
// 作用于没有写端口的主机。结果去重并保持输入顺序。
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 {
// 自带端口的完整目标:"10.0.1.11: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 {
return nil, fmt.Errorf("无效的目标 %q", item)
}
if err := add(net.JoinHostPort(h, p)); err != nil {
return nil, err
}
continue
}
hostsOfItem, isRange, err := expandIPv4Range(item)
if err != nil {
return nil, err
}
if !isRange {
if !validHost(item) {
return nil, fmt.Errorf("无效的主机 %q", item)
}
hostsOfItem = []string{item}
}
if len(portList) == 0 {
return nil, fmt.Errorf("请填写端口,或把目标写成 主机:端口")
}
for _, h := range hostsOfItem {
for _, p := range portList {
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)
}