feat: 通道组——一次开放多台主机 / 多个端口,共用邀请与授权

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

No files matched your search

+88 -65
View File
@@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@@ -42,27 +43,27 @@ type leaseInfo struct {
// newLease creates a lease whose countdown starts at the device's first connection.
// Leases valid for the whole tunnel lifetime start immediately.
func newLease(name, invite, duration string, tunnelExpires *time.Time, now time.Time) leaseInfo {
func newLease(name, invite, duration string, scopeExpires *time.Time, now time.Time) leaseInfo {
l := leaseInfo{Name: name, Invite: invite, Duration: duration}
if duration == protocol.LeaseDurationTunnel {
l.StartedAt = &now
l.ExpiresAt = leaseExpiry(duration, tunnelExpires, now)
l.ExpiresAt = leaseExpiry(duration, scopeExpires, now)
return l
}
l.Deferred = true
l.ExpiresAt = now.Add(leaseStartWindow)
if tunnelExpires != nil && tunnelExpires.After(now) && tunnelExpires.Before(l.ExpiresAt) {
l.ExpiresAt = *tunnelExpires
if scopeExpires != nil && scopeExpires.After(now) && scopeExpires.Before(l.ExpiresAt) {
l.ExpiresAt = *scopeExpires
}
return l
}
// startLease starts the countdown of a deferred lease on the device's first connection.
// Returns the new expiry and true when the lease was started now.
func (s *stateStore) startLease(tunnelID, ip string, tunnelExpires *time.Time, now time.Time) (time.Time, bool) {
func (s *stateStore) startLease(scope, ip string, scopeExpires *time.Time, now time.Time) (time.Time, bool) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
st := s.data[scope]
if st == nil {
return time.Time{}, false
}
@@ -72,16 +73,16 @@ func (s *stateStore) startLease(tunnelID, ip string, tunnelExpires *time.Time, n
}
l.Deferred = false
l.StartedAt = &now
l.ExpiresAt = leaseExpiry(l.Duration, tunnelExpires, now)
l.ExpiresAt = leaseExpiry(l.Duration, scopeExpires, now)
st.Leases[ip] = l
s.saveLocked()
return l.ExpiresAt, true
}
func (s *stateStore) lease(tunnelID, ip string) (leaseInfo, bool) {
func (s *stateStore) lease(scope, ip string) (leaseInfo, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if st := s.data[tunnelID]; st != nil {
if st := s.data[scope]; st != nil {
l, ok := st.Leases[ip]
return l, ok && time.Now().Before(l.ExpiresAt)
}
@@ -94,12 +95,33 @@ type tunnelState struct {
Requests map[string]*protocol.AccessRequest `json:"requests,omitempty"` // id -> request
}
// stateStore keeps per-tunnel access state (leases, invites, approval requests).
// 授权状态的作用范围:单个通道(键为通道 ID,与旧数据兼容)或整个通道组(键为 "group:<组ID>")
const groupScopePrefix = "group:"
func groupScope(groupID string) string { return groupScopePrefix + groupID }
// scopeOf returns the state key of a tunnel, or of its group when groupID is set
func scopeOf(tunnelID, groupID string) string {
if groupID != "" {
return groupScope(groupID)
}
return tunnelID
}
// splitScope returns the tunnel or group ID of a scope and whether it is a group scope
func splitScope(scope string) (id string, isGroup bool) {
if strings.HasPrefix(scope, groupScopePrefix) {
return strings.TrimPrefix(scope, groupScopePrefix), true
}
return scope, false
}
// stateStore keeps per-scope access state (leases, invites, approval requests).
// It lives in memory and is optionally persisted to a file so it survives restarts.
type stateStore struct {
path string
mu sync.Mutex
data map[string]*tunnelState // tunnelID -> state
data map[string]*tunnelState // scope(通道 ID 或 group:<组ID>)-> state
}
func newStateStore() *stateStore {
@@ -116,7 +138,7 @@ func (m *Manager) EnableLeasePersistence(path string) error {
switch {
case err == nil:
if err := json.Unmarshal(raw, &data); err != nil || !looksLikeState(data) {
// 兼容旧格式:tunnelID -> ip -> 到期时间
// 兼容旧格式:通道 ID -> ip -> 到期时间
var legacy map[string]map[string]time.Time
if json.Unmarshal(raw, &legacy) == nil {
data = make(map[string]*tunnelState)
@@ -162,7 +184,7 @@ func looksLikeState(d map[string]*tunnelState) bool {
return true
}
func (s *stateStore) tunnelLocked(id string) *tunnelState {
func (s *stateStore) scopeLocked(id string) *tunnelState {
st := s.data[id]
if st == nil {
st = &tunnelState{}
@@ -195,35 +217,32 @@ func randomCode(n int) string {
// ---- leases ----
func (s *stateStore) grantLease(tunnelID, ip string, exp time.Time, name, invite string) {
// removeLease removes one lease, or all leases of the scope when ip is empty; returns how many were removed
func (s *stateStore) removeLease(scope, ip string) int {
s.mu.Lock()
defer s.mu.Unlock()
s.tunnelLocked(tunnelID).Leases[ip] = leaseInfo{ExpiresAt: exp, Name: name, Invite: invite}
s.saveLocked()
}
// removeLease removes one lease, or all leases of the tunnel when ip is empty
func (s *stateStore) removeLease(tunnelID, ip string) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
st := s.data[scope]
if st == nil {
return
return 0
}
n := 0
if ip == "" {
n = len(st.Leases)
st.Leases = make(map[string]leaseInfo)
} else {
} else if _, ok := st.Leases[ip]; ok {
n = 1
delete(st.Leases, ip)
}
s.saveLocked()
return n
}
func (s *stateStore) leaseExpiries(tunnelID string) map[string]time.Time {
func (s *stateStore) leaseExpiries(scope string) map[string]time.Time {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
out := make(map[string]time.Time)
if st := s.data[tunnelID]; st != nil {
if st := s.data[scope]; st != nil {
for ip, l := range st.Leases {
if now.Before(l.ExpiresAt) {
out[ip] = l.ExpiresAt
@@ -233,12 +252,12 @@ func (s *stateStore) leaseExpiries(tunnelID string) map[string]time.Time {
return out
}
func (s *stateStore) leaseList(tunnelID string) []protocol.DynamicLease {
func (s *stateStore) leaseList(scope string) []protocol.DynamicLease {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
var out []protocol.DynamicLease
if st := s.data[tunnelID]; st != nil {
if st := s.data[scope]; st != nil {
for ip, l := range st.Leases {
if now.Before(l.ExpiresAt) {
out = append(out, protocol.DynamicLease{
@@ -252,19 +271,19 @@ func (s *stateStore) leaseList(tunnelID string) []protocol.DynamicLease {
return out
}
// forgetTunnel drops all access state of a tunnel (explicitly closed or deleted)
func (s *stateStore) forgetTunnel(tunnelID string) {
// forgetScope drops all access state of a tunnel or group (explicitly closed, deleted or dissolved)
func (s *stateStore) forgetScope(scope string) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.data[tunnelID]; ok {
delete(s.data, tunnelID)
if _, ok := s.data[scope]; ok {
delete(s.data, scope)
s.saveLocked()
}
}
// ---- invites ----
func (s *stateStore) createInvite(tunnelID, note string, valid time.Duration, maxUses int, leaseDuration string, requireApproval bool) protocol.Invite {
func (s *stateStore) createInvite(scope, note string, valid time.Duration, maxUses int, leaseDuration string, requireApproval bool) protocol.Invite {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
@@ -280,12 +299,12 @@ func (s *stateStore) createInvite(tunnelID, note string, valid time.Duration, ma
LeaseDuration: leaseDuration,
RequireApproval: requireApproval,
}
s.tunnelLocked(tunnelID).Invites[inv.Code] = inv
s.scopeLocked(scope).Invites[inv.Code] = inv
s.saveLocked()
return *inv
}
// findInvite returns the tunnel owning an unexpired invite
// findInvite returns the scope (tunnel or group) owning an unexpired invite
func (s *stateStore) findInvite(code string) (string, protocol.Invite, bool) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -317,10 +336,10 @@ func capacityOKLocked(st *tunnelState, inv *protocol.Invite, ip string) bool {
}
// checkInvite validates that ip may use the invite right now
func (s *stateStore) checkInvite(tunnelID, code, ip string) error {
func (s *stateStore) checkInvite(scope, code, ip string) error {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
st := s.data[scope]
if st == nil {
return errInviteNotFound
}
@@ -349,10 +368,10 @@ func (s *stateStore) recordUseLocked(st *tunnelState, code, name, ip string) {
}
// useInviteAndGrant records the use and stores the lease atomically; returns the lease
func (s *stateStore) useInviteAndGrant(tunnelID, code, name, ip string, tunnelExpires *time.Time) (leaseInfo, error) {
func (s *stateStore) useInviteAndGrant(scope, code, name, ip string, scopeExpires *time.Time) (leaseInfo, error) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
st := s.data[scope]
if st == nil {
return leaseInfo{}, errInviteNotFound
}
@@ -363,19 +382,19 @@ func (s *stateStore) useInviteAndGrant(tunnelID, code, name, ip string, tunnelEx
if !capacityOKLocked(st, inv, ip) {
return leaseInfo{}, errInviteUsedUp
}
st = s.tunnelLocked(tunnelID)
st = s.scopeLocked(scope)
s.recordUseLocked(st, code, name, ip)
l := newLease(name, code, inv.LeaseDuration, tunnelExpires, time.Now())
l := newLease(name, code, inv.LeaseDuration, scopeExpires, time.Now())
st.Leases[ip] = l
s.saveLocked()
return l, nil
}
// revokeInvite removes the invite and the leases it granted; returns the affected IPs
func (s *stateStore) revokeInvite(tunnelID, code string) []string {
func (s *stateStore) revokeInvite(scope, code string) []string {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
st := s.data[scope]
if st == nil {
return nil
}
@@ -396,12 +415,12 @@ func (s *stateStore) revokeInvite(tunnelID, code string) []string {
return ips
}
func (s *stateStore) inviteList(tunnelID string) []protocol.Invite {
func (s *stateStore) inviteList(scope string) []protocol.Invite {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
var out []protocol.Invite
if st := s.data[tunnelID]; st != nil {
if st := s.data[scope]; st != nil {
for _, inv := range st.Invites {
if now.Before(inv.ExpiresAt) {
c := *inv
@@ -417,10 +436,10 @@ func (s *stateStore) inviteList(tunnelID string) []protocol.Invite {
// ---- approval requests ----
// addRequest files a pending request (re-using an existing pending one from the same IP)
func (s *stateStore) addRequest(tunnelID, code, name, ip string) (protocol.AccessRequest, error) {
func (s *stateStore) addRequest(scope, code, name, ip string) (protocol.AccessRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
st := s.data[scope]
if st == nil {
return protocol.AccessRequest{}, errInviteNotFound
}
@@ -428,7 +447,7 @@ func (s *stateStore) addRequest(tunnelID, code, name, ip string) (protocol.Acces
if !ok || !time.Now().Before(inv.ExpiresAt) {
return protocol.AccessRequest{}, errInviteNotFound
}
st = s.tunnelLocked(tunnelID)
st = s.scopeLocked(scope)
for _, r := range st.Requests {
if r.IP == ip && r.Status == "pending" {
r.Name = name
@@ -440,13 +459,17 @@ func (s *stateStore) addRequest(tunnelID, code, name, ip string) (protocol.Acces
return protocol.AccessRequest{}, errInviteUsedUp
}
r := &protocol.AccessRequest{
ID: randomCode(16),
TunnelID: tunnelID,
Name: name,
IP: ip,
Invite: code,
At: time.Now(),
Status: "pending",
ID: randomCode(16),
Name: name,
IP: ip,
Invite: code,
At: time.Now(),
Status: "pending",
}
if id, isGroup := splitScope(scope); isGroup {
r.GroupID = id
} else {
r.TunnelID = id
}
st.Requests[r.ID] = r
s.saveLocked()
@@ -467,11 +490,11 @@ func (s *stateStore) request(id string) (protocol.AccessRequest, bool) {
// decide marks a pending request; on approval it records the invite use and stores the lease,
// lasting as long as the originating invite specifies (capped by the tunnel's own TTL).
func (s *stateStore) decide(tunnelID, id string, approve bool, tunnelExpires *time.Time) (protocol.AccessRequest, time.Time, error) {
func (s *stateStore) decide(scope, id string, approve bool, scopeExpires *time.Time) (protocol.AccessRequest, time.Time, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.pruneRequestsLocked()
st := s.data[tunnelID]
st := s.data[scope]
if st == nil {
return protocol.AccessRequest{}, time.Time{}, errRequestGone
}
@@ -479,12 +502,12 @@ func (s *stateStore) decide(tunnelID, id string, approve bool, tunnelExpires *ti
if !ok || r.Status != "pending" {
return protocol.AccessRequest{}, time.Time{}, errRequestGone
}
st = s.tunnelLocked(tunnelID)
st = s.scopeLocked(scope)
duration := ""
if inv, ok := st.Invites[r.Invite]; ok {
duration = inv.LeaseDuration
}
l := newLease(r.Name, r.Invite, duration, tunnelExpires, time.Now())
l := newLease(r.Name, r.Invite, duration, scopeExpires, time.Now())
exp := l.ExpiresAt
r.At = time.Now()
if approve {
@@ -498,12 +521,12 @@ func (s *stateStore) decide(tunnelID, id string, approve bool, tunnelExpires *ti
return *r, exp, nil
}
func (s *stateStore) pendingList(tunnelID string) []protocol.AccessRequest {
func (s *stateStore) pendingList(scope string) []protocol.AccessRequest {
s.mu.Lock()
defer s.mu.Unlock()
s.pruneRequestsLocked()
var out []protocol.AccessRequest
if st := s.data[tunnelID]; st != nil {
if st := s.data[scope]; st != nil {
for _, r := range st.Requests {
if r.Status == "pending" {
out = append(out, *r)
@@ -581,7 +604,7 @@ func (s *stateStore) saveLocked() {
// leaseExpiry computes when a lease granted now ends, per the invite's lease duration,
// never outliving the tunnel's own TTL.
func leaseExpiry(duration string, tunnelExpires *time.Time, now time.Time) time.Time {
func leaseExpiry(duration string, scopeExpires *time.Time, now time.Time) time.Time {
var exp time.Time
switch duration {
case protocol.LeaseDuration1h:
@@ -597,8 +620,8 @@ func leaseExpiry(duration string, tunnelExpires *time.Time, now time.Time) time.
default:
exp = now.Add(2 * time.Hour)
}
if tunnelExpires != nil && tunnelExpires.After(now) && tunnelExpires.Before(exp) {
exp = *tunnelExpires
if scopeExpires != nil && scopeExpires.After(now) && scopeExpires.Before(exp) {
exp = *scopeExpires
}
return exp
}