feat: 通道组——一次开放多台主机 / 多个端口,共用邀请与授权
- 批量创建:主机支持 IP、主机名、末段范围(10.0.1.11-13)和 主机:端口, 与端口列表展开为 主机 × 端口,自动分配连续的访问端口,创建前可预览 - 授权以组为单位:一条邀请链接、一次审批即可访问组内全部地址; 计时从首次连上任一地址开始,撤销一次全组断开;后加入组的通道自动获得组授权 - 网关授权状态按作用范围(通道 / 通道组)存储,通道的动态白名单统一由状态库重新计算, 通道授权与组授权互不干扰;组内最后一个通道关闭或组被解散时,组的邀请与授权失效 - 闲置联动:组内任一地址在用时整组都不算闲置;支持整组开关 - 已有通道可并入组或移出组;组可改名、追加目标、解散(保留或删除成员) - App 与网页面板:组卡片、新建 / 编辑通道组、组邀请与“连接与授权”,授权页列出全部地址 - 测试:网关 9 个、Agent 5 个通道组用例
This commit is contained in:
1 parent
86e159b3f0
commit
3797bef88a
22 files changed
+3894
-471
No files matched your search
+209
@@ -0,0 +1,209 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user