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

628 lines
17 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 server
import (
"crypto/rand"
"encoding/json"
"errors"
"io/fs"
"log"
"math/big"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/DeepFal/PortRelay/core/protocol"
)
const (
// 待审批申请的有效期;已处理的申请保留同样时长,供申请人页面查询结果
requestTTL = 30 * time.Minute
inviteCodeLen = 10
// leaseStartWindow 授权后需在多久内首次连接;授权时长从首次连接开始计算
leaseStartWindow = 24 * time.Hour
)
var (
errInviteNotFound = errors.New("邀请链接无效或已过期")
errInviteUsedUp = errors.New("该邀请链接的可用设备数已用完,请向管理员索取新的链接")
errRequestGone = errors.New("申请不存在或已过期")
)
type leaseInfo struct {
ExpiresAt time.Time `json:"expires_at"` // 计时开始前:首次连接截止时间;开始后:授权到期时间
Name string `json:"name,omitempty"`
Invite string `json:"invite,omitempty"`
Duration string `json:"duration,omitempty"`
// Deferred 为 true 表示尚未首次连接,计时未开始
Deferred bool `json:"deferred,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
}
// 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, 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, scopeExpires, now)
return l
}
l.Deferred = true
l.ExpiresAt = now.Add(leaseStartWindow)
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(scope, ip string, scopeExpires *time.Time, now time.Time) (time.Time, bool) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[scope]
if st == nil {
return time.Time{}, false
}
l, ok := st.Leases[ip]
if !ok || !l.Deferred || !now.Before(l.ExpiresAt) {
return time.Time{}, false
}
l.Deferred = false
l.StartedAt = &now
l.ExpiresAt = leaseExpiry(l.Duration, scopeExpires, now)
st.Leases[ip] = l
s.saveLocked()
return l.ExpiresAt, true
}
func (s *stateStore) lease(scope, ip string) (leaseInfo, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if st := s.data[scope]; st != nil {
l, ok := st.Leases[ip]
return l, ok && time.Now().Before(l.ExpiresAt)
}
return leaseInfo{}, false
}
type tunnelState struct {
Leases map[string]leaseInfo `json:"leases,omitempty"` // ip -> lease
Invites map[string]*protocol.Invite `json:"invites,omitempty"` // code -> invite
Requests map[string]*protocol.AccessRequest `json:"requests,omitempty"` // id -> request
}
// 授权状态的作用范围:单个通道(键为通道 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 // scope(通道 ID 或 group:<组ID>)-> state
}
func newStateStore() *stateStore {
return &stateStore{data: make(map[string]*tunnelState)}
}
// EnableLeasePersistence loads access state from path and keeps it updated
func (m *Manager) EnableLeasePersistence(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
raw, err := os.ReadFile(path)
data := make(map[string]*tunnelState)
switch {
case err == nil:
if err := json.Unmarshal(raw, &data); err != nil || !looksLikeState(data) {
// 兼容旧格式:通道 ID -> ip -> 到期时间
var legacy map[string]map[string]time.Time
if json.Unmarshal(raw, &legacy) == nil {
data = make(map[string]*tunnelState)
for id, ips := range legacy {
st := &tunnelState{Leases: make(map[string]leaseInfo)}
for ip, exp := range ips {
st.Leases[ip] = leaseInfo{ExpiresAt: exp}
}
data[id] = st
}
} else {
log.Printf("[WebAuth] ignoring unreadable state file %s: %v", path, err)
data = make(map[string]*tunnelState)
}
}
case !errors.Is(err, fs.ErrNotExist):
return err
}
s := m.state
s.mu.Lock()
s.path = path
s.data = data
s.pruneLocked()
n := 0
for _, st := range s.data {
n += len(st.Leases)
}
s.mu.Unlock()
if n > 0 {
log.Printf("[WebAuth] Loaded %d unexpired temporary lease(s) from %s", n, path)
}
return nil
}
// looksLikeState distinguishes the new format from the legacy one (both are JSON objects of objects)
func looksLikeState(d map[string]*tunnelState) bool {
for _, st := range d {
if st == nil || (st.Leases == nil && st.Invites == nil && st.Requests == nil) {
return false
}
}
return true
}
func (s *stateStore) scopeLocked(id string) *tunnelState {
st := s.data[id]
if st == nil {
st = &tunnelState{}
s.data[id] = st
}
if st.Leases == nil {
st.Leases = make(map[string]leaseInfo)
}
if st.Invites == nil {
st.Invites = make(map[string]*protocol.Invite)
}
if st.Requests == nil {
st.Requests = make(map[string]*protocol.AccessRequest)
}
return st
}
func randomCode(n int) string {
const chars = "23456789abcdefghjkmnpqrstuvwxyz"
b := make([]byte, n)
for i := range b {
v, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
if err != nil {
panic(err)
}
b[i] = chars[v.Int64()]
}
return string(b)
}
// ---- leases ----
// 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()
st := s.data[scope]
if st == nil {
return 0
}
n := 0
if ip == "" {
n = len(st.Leases)
st.Leases = make(map[string]leaseInfo)
} else if _, ok := st.Leases[ip]; ok {
n = 1
delete(st.Leases, ip)
}
s.saveLocked()
return n
}
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[scope]; st != nil {
for ip, l := range st.Leases {
if now.Before(l.ExpiresAt) {
out[ip] = l.ExpiresAt
}
}
}
return out
}
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[scope]; st != nil {
for ip, l := range st.Leases {
if now.Before(l.ExpiresAt) {
out = append(out, protocol.DynamicLease{
IP: ip, ExpiresAt: l.ExpiresAt, Name: l.Name, Invite: l.Invite,
Duration: l.Duration, AwaitingConnect: l.Deferred, StartedAt: l.StartedAt,
})
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ExpiresAt.Before(out[j].ExpiresAt) })
return out
}
// 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[scope]; ok {
delete(s.data, scope)
s.saveLocked()
}
}
// ---- invites ----
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()
if leaseDuration == "" {
leaseDuration = protocol.LeaseDuration2h
}
inv := &protocol.Invite{
Code: randomCode(inviteCodeLen),
Note: note,
CreatedAt: now,
ExpiresAt: now.Add(valid),
MaxUses: maxUses,
LeaseDuration: leaseDuration,
RequireApproval: requireApproval,
}
s.scopeLocked(scope).Invites[inv.Code] = inv
s.saveLocked()
return *inv
}
// 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()
now := time.Now()
for id, st := range s.data {
if inv, ok := st.Invites[code]; ok && now.Before(inv.ExpiresAt) {
return id, *inv, true
}
}
return "", protocol.Invite{}, false
}
// capacityOKLocked reports whether ip may use the invite: devices that already used it
// (or have a pending request through it) may come back; new devices need a free slot.
func capacityOKLocked(st *tunnelState, inv *protocol.Invite, ip string) bool {
if inv.MaxUses <= 0 {
return true
}
devices := make(map[string]bool)
for _, u := range inv.Uses {
devices[u.IP] = true
}
for _, r := range st.Requests {
if r.Invite == inv.Code && r.Status == "pending" {
devices[r.IP] = true
}
}
return devices[ip] || len(devices) < inv.MaxUses
}
// checkInvite validates that ip may use the invite right now
func (s *stateStore) checkInvite(scope, code, ip string) error {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[scope]
if st == nil {
return errInviteNotFound
}
inv, ok := st.Invites[code]
if !ok || !time.Now().Before(inv.ExpiresAt) {
return errInviteNotFound
}
if !capacityOKLocked(st, inv, ip) {
return errInviteUsedUp
}
return nil
}
func (s *stateStore) recordUseLocked(st *tunnelState, code, name, ip string) {
inv, ok := st.Invites[code]
if !ok {
return
}
for i, u := range inv.Uses {
if u.IP == ip {
inv.Uses[i] = protocol.InviteUse{Name: name, IP: ip, At: time.Now()}
return
}
}
inv.Uses = append(inv.Uses, protocol.InviteUse{Name: name, IP: ip, At: time.Now()})
}
// useInviteAndGrant records the use and stores the lease atomically; returns the lease
func (s *stateStore) useInviteAndGrant(scope, code, name, ip string, scopeExpires *time.Time) (leaseInfo, error) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[scope]
if st == nil {
return leaseInfo{}, errInviteNotFound
}
inv, ok := st.Invites[code]
if !ok || !time.Now().Before(inv.ExpiresAt) {
return leaseInfo{}, errInviteNotFound
}
if !capacityOKLocked(st, inv, ip) {
return leaseInfo{}, errInviteUsedUp
}
st = s.scopeLocked(scope)
s.recordUseLocked(st, code, name, ip)
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(scope, code string) []string {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[scope]
if st == nil {
return nil
}
delete(st.Invites, code)
var ips []string
for ip, l := range st.Leases {
if l.Invite == code {
ips = append(ips, ip)
delete(st.Leases, ip)
}
}
for id, r := range st.Requests {
if r.Invite == code && r.Status == "pending" {
delete(st.Requests, id)
}
}
s.saveLocked()
return ips
}
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[scope]; st != nil {
for _, inv := range st.Invites {
if now.Before(inv.ExpiresAt) {
c := *inv
c.Uses = append([]protocol.InviteUse(nil), inv.Uses...)
out = append(out, c)
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out
}
// ---- approval requests ----
// addRequest files a pending request (re-using an existing pending one from the same IP)
func (s *stateStore) addRequest(scope, code, name, ip string) (protocol.AccessRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[scope]
if st == nil {
return protocol.AccessRequest{}, errInviteNotFound
}
inv, ok := st.Invites[code]
if !ok || !time.Now().Before(inv.ExpiresAt) {
return protocol.AccessRequest{}, errInviteNotFound
}
st = s.scopeLocked(scope)
for _, r := range st.Requests {
if r.IP == ip && r.Status == "pending" {
r.Name = name
s.saveLocked()
return *r, nil
}
}
if !capacityOKLocked(st, inv, ip) {
return protocol.AccessRequest{}, errInviteUsedUp
}
r := &protocol.AccessRequest{
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()
return *r, nil
}
func (s *stateStore) request(id string) (protocol.AccessRequest, bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.pruneRequestsLocked()
for _, st := range s.data {
if r, ok := st.Requests[id]; ok {
return *r, true
}
}
return protocol.AccessRequest{}, false
}
// 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(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[scope]
if st == nil {
return protocol.AccessRequest{}, time.Time{}, errRequestGone
}
r, ok := st.Requests[id]
if !ok || r.Status != "pending" {
return protocol.AccessRequest{}, time.Time{}, errRequestGone
}
st = s.scopeLocked(scope)
duration := ""
if inv, ok := st.Invites[r.Invite]; ok {
duration = inv.LeaseDuration
}
l := newLease(r.Name, r.Invite, duration, scopeExpires, time.Now())
exp := l.ExpiresAt
r.At = time.Now()
if approve {
r.Status = "approved"
s.recordUseLocked(st, r.Invite, r.Name, r.IP)
st.Leases[r.IP] = l
} else {
r.Status = "rejected"
}
s.saveLocked()
return *r, exp, nil
}
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[scope]; st != nil {
for _, r := range st.Requests {
if r.Status == "pending" {
out = append(out, *r)
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
return out
}
// ---- housekeeping ----
func (s *stateStore) pruneRequestsLocked() {
cutoff := time.Now().Add(-requestTTL)
for _, st := range s.data {
for id, r := range st.Requests {
if r.At.Before(cutoff) {
delete(st.Requests, id)
}
}
}
}
func (s *stateStore) pruneLocked() {
now := time.Now()
s.pruneRequestsLocked()
for id, st := range s.data {
if st == nil {
delete(s.data, id)
continue
}
for ip, l := range st.Leases {
if !now.Before(l.ExpiresAt) {
delete(st.Leases, ip)
}
}
for code, inv := range st.Invites {
if !now.Before(inv.ExpiresAt) {
delete(st.Invites, code)
}
}
if len(st.Leases) == 0 && len(st.Invites) == 0 && len(st.Requests) == 0 {
delete(s.data, id)
}
}
}
func (s *stateStore) saveLocked() {
s.pruneLocked()
if s.path == "" {
return
}
data, err := json.Marshal(s.data)
if err != nil {
return
}
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".state-*.json")
if err != nil {
log.Printf("[WebAuth] save state: %v", err)
return
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
tmp.Close()
log.Printf("[WebAuth] save state: %v", err)
return
}
if err := tmp.Close(); err != nil {
return
}
if err := os.Rename(tmp.Name(), s.path); err != nil {
log.Printf("[WebAuth] save state: %v", err)
}
}
// 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, scopeExpires *time.Time, now time.Time) time.Time {
var exp time.Time
switch duration {
case protocol.LeaseDuration1h:
exp = now.Add(time.Hour)
case protocol.LeaseDuration8h:
exp = now.Add(8 * time.Hour)
case protocol.LeaseDurationToday:
y, mo, d := now.Date()
exp = time.Date(y, mo, d, 23, 59, 59, 0, now.Location())
case protocol.LeaseDurationTunnel:
// 通道开启期间有效:通道被主动关闭时放通会被清除;设一个上限避免永久放通
exp = now.Add(30 * 24 * time.Hour)
default:
exp = now.Add(2 * time.Hour)
}
if scopeExpires != nil && scopeExpires.After(now) && scopeExpires.Before(exp) {
exp = *scopeExpires
}
return exp
}