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

467 lines
11 KiB
Go

package server
import (
"encoding/json"
"errors"
"fmt"
"log"
"net"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/hashicorp/yamux"
"github.com/DeepFal/PortRelay/core/acl"
"github.com/DeepFal/PortRelay/core/protocol"
"github.com/DeepFal/PortRelay/core/traffic"
)
// ActiveClient records a live client TCP connection
type ActiveClient struct {
ID string
ClientIP string
ConnectedAt time.Time
Conn net.Conn
Meter *traffic.Meter
}
// Tunnel manages a single edge listening port
type Tunnel struct {
Config protocol.TunnelConfig
listener net.Listener
whitelist *acl.Whitelist
limiter *traffic.Limiter
mu sync.RWMutex
activeConns map[string]*ActiveClient
totalConns atomic.Int64
blockedAttemps atomic.Int64
meter *traffic.Meter
closed chan struct{}
closeOnce sync.Once
session *yamux.Session
onSecEvent func(ev protocol.SecurityEvent)
// onConnect 在客户端通过访问控制、连接建立时调用(用于开始授权计时)
onConnect func(tunnelID, ip string)
}
// NewTunnel creates and binds a new tunnel listener
func NewTunnel(cfg protocol.TunnelConfig, session *yamux.Session, onSecEvent func(protocol.SecurityEvent)) (*Tunnel, error) {
return newTunnel(cfg, session, onSecEvent, nil, nil)
}
// newTunnel binds a listener; when prev is set, its traffic counters and dynamic leases carry over
// (used when the same tunnel is re-registered or moved to another port).
func newTunnel(cfg protocol.TunnelConfig, session *yamux.Session, onSecEvent func(protocol.SecurityEvent), onConnect func(tunnelID, ip string), prev *Tunnel) (*Tunnel, error) {
addr := fmt.Sprintf("0.0.0.0:%d", cfg.ListenPort)
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("failed to bind port %d: %w", cfg.ListenPort, err)
}
t := &Tunnel{
Config: cfg,
listener: ln,
whitelist: acl.NewWhitelist(cfg.Whitelist),
limiter: traffic.NewLimiter(cfg.MaxBandwidthMB),
activeConns: make(map[string]*ActiveClient),
meter: traffic.NewMeter(),
closed: make(chan struct{}),
session: session,
onSecEvent: onSecEvent,
onConnect: onConnect,
}
if prev != nil {
t.meter = prev.meter
t.totalConns.Store(prev.totalConns.Load())
t.blockedAttemps.Store(prev.blockedAttemps.Load())
t.whitelist.RestoreDynamic(prev.dynamicLeases())
}
go t.acceptLoop()
return t, nil
}
// UpdateConfig updates whitelist, rate limiting and metadata dynamically (existing connections are kept)
func (t *Tunnel) UpdateConfig(cfg protocol.TunnelConfig) {
t.mu.Lock()
defer t.mu.Unlock()
dynamic := t.whitelist.DynamicLeases()
t.Config = cfg
t.whitelist = acl.NewWhitelist(cfg.Whitelist)
t.whitelist.RestoreDynamic(dynamic)
if t.limiter == nil || t.limiter.MBPerSec() != cfg.MaxBandwidthMB {
t.limiter = traffic.NewLimiter(cfg.MaxBandwidthMB)
}
}
// ListenPort returns the bound port
func (t *Tunnel) ListenPort() int {
t.mu.RLock()
defer t.mu.RUnlock()
return t.Config.ListenPort
}
// Snapshot returns a copy of the current tunnel config
func (t *Tunnel) Snapshot() protocol.TunnelConfig {
t.mu.RLock()
defer t.mu.RUnlock()
return t.Config
}
// ID returns the tunnel ID
func (t *Tunnel) ID() string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.Config.ID
}
// SetDynamic replaces the temporary leases with the given set and disconnects
// active clients that are no longer permitted. Returns how many were disconnected.
func (t *Tunnel) SetDynamic(leases map[string]time.Time) int {
t.mu.Lock()
defer t.mu.Unlock()
t.whitelist.ReplaceDynamic(leases)
kicked := 0
for id, ac := range t.activeConns {
if ok, _ := t.whitelist.IsAllowed(ac.ClientIP); !ok {
_ = ac.Conn.Close()
delete(t.activeConns, id)
kicked++
}
}
return kicked
}
// GroupID returns the tunnel group this tunnel belongs to ("" when none)
func (t *Tunnel) GroupID() string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.Config.GroupID
}
// Name returns the tunnel display name
func (t *Tunnel) Name() string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.Config.Name
}
// AuthCode returns the web auth code
func (t *Tunnel) AuthCode() string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.Config.AuthCode
}
// AddDynamicWhitelist grants temporary IP access
func (t *Tunnel) AddDynamicWhitelist(ip string, exp time.Time) {
t.mu.RLock()
defer t.mu.RUnlock()
t.whitelist.AddDynamic(ip, exp)
}
// GetDynamicWhitelist returns active granted IPs
func (t *Tunnel) GetDynamicWhitelist() []string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.whitelist.GetDynamic()
}
// RevokeDynamic removes temporary leases (all when ip is empty) and disconnects
// active clients that are no longer permitted by the remaining rules.
func (t *Tunnel) RevokeDynamic(ip string) (revoked, kicked int) {
t.mu.Lock()
defer t.mu.Unlock()
revoked = t.whitelist.RemoveDynamic(ip)
if revoked == 0 {
return 0, 0
}
for id, ac := range t.activeConns {
if ok, _ := t.whitelist.IsAllowed(ac.ClientIP); !ok {
_ = ac.Conn.Close()
delete(t.activeConns, id)
kicked++
}
}
return revoked, kicked
}
// EnforceAccess disconnects active clients that are no longer permitted — e.g. their
// temporary lease expired or the whitelist changed. Returns the disconnected client addresses.
func (t *Tunnel) EnforceAccess() []string {
t.mu.Lock()
defer t.mu.Unlock()
var kicked []string
for id, ac := range t.activeConns {
if ok, _ := t.whitelist.IsAllowed(ac.ClientIP); !ok {
_ = ac.Conn.Close()
delete(t.activeConns, id)
kicked = append(kicked, ac.ClientIP)
}
}
return kicked
}
func (t *Tunnel) dynamicLeases() map[string]time.Time {
t.mu.RLock()
defer t.mu.RUnlock()
return t.whitelist.DynamicLeases()
}
func (t *Tunnel) acceptLoop() {
var backoff time.Duration
for {
clientConn, err := t.listener.Accept()
if err != nil {
select {
case <-t.closed:
return
default:
}
if errors.Is(err, net.ErrClosed) {
return
}
// 临时错误(如文件句柄耗尽)退避后继续,不能让监听永久退出
if backoff == 0 {
backoff = 5 * time.Millisecond
} else if backoff *= 2; backoff > time.Second {
backoff = time.Second
}
log.Printf("[Tunnel %d] accept error: %v; retrying in %v", t.ListenPort(), err, backoff)
select {
case <-t.closed:
return
case <-time.After(backoff):
}
continue
}
backoff = 0
go t.handleClient(clientConn)
}
}
func (t *Tunnel) handleClient(clientConn net.Conn) {
remoteAddr := clientConn.RemoteAddr().String()
// 1. Check IP Whitelist immediately at the edge
t.mu.RLock()
allowed, reason := t.whitelist.IsAllowed(remoteAddr)
tunnelID := t.Config.ID
listenPort := t.Config.ListenPort
t.mu.RUnlock()
if !allowed {
t.blockedAttemps.Add(1)
_ = clientConn.Close()
if t.onSecEvent != nil {
t.onSecEvent(protocol.SecurityEvent{
ID: uuid.New().String(),
TunnelID: tunnelID,
Port: listenPort,
ClientIP: remoteAddr,
Timestamp: time.Now(),
Reason: reason,
})
}
log.Printf("[Tunnel %d] BLOCKED connection from %s (%s)", listenPort, remoteAddr, reason)
return
}
t.totalConns.Add(1)
connID := uuid.New().String()
if t.onConnect != nil {
host := remoteAddr
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = h
}
t.onConnect(tunnelID, host)
}
// 2. Open Yamux stream to Mac agent
stream, err := t.session.OpenStream()
if err != nil {
log.Printf("[Tunnel %d] failed to open stream to agent: %v", listenPort, err)
_ = clientConn.Close()
return
}
// 3. Send handshake line
handshake := protocol.StreamHandshake{
TunnelID: tunnelID,
ConnID: connID,
ClientIP: remoteAddr,
}
data, _ := json.Marshal(handshake)
data = append(data, '\n')
if _, err := stream.Write(data); err != nil {
_ = stream.Close()
_ = clientConn.Close()
return
}
// 4. Track active connection
connMeter := traffic.NewMeter()
wrappedClientConn := traffic.WrapConn(clientConn, connMeter)
ac := &ActiveClient{
ID: connID,
ClientIP: remoteAddr,
ConnectedAt: time.Now(),
Conn: clientConn,
Meter: connMeter,
}
t.mu.Lock()
t.activeConns[connID] = ac
t.mu.Unlock()
defer func() {
t.mu.Lock()
delete(t.activeConns, connID)
t.mu.Unlock()
_ = wrappedClientConn.Close()
_ = stream.Close()
}()
// 5. Bidirectional copy with optional QoS rate limiting
done := make(chan struct{}, 2)
go func() {
buf := make([]byte, 32*1024)
for {
nr, er := wrappedClientConn.Read(buf)
if nr > 0 {
t.meter.AddIn(int64(nr))
t.mu.RLock()
lim := t.limiter
t.mu.RUnlock()
if lim != nil {
lim.Allow(nr)
}
nw, ew := stream.Write(buf[0:nr])
if ew != nil || nr != nw {
break
}
}
if er != nil {
break
}
}
_ = stream.Close()
_ = wrappedClientConn.Close()
done <- struct{}{}
}()
go func() {
buf := make([]byte, 32*1024)
for {
nr, er := stream.Read(buf)
if nr > 0 {
t.meter.AddOut(int64(nr))
t.mu.RLock()
lim := t.limiter
t.mu.RUnlock()
if lim != nil {
lim.Allow(nr)
}
nw, ew := wrappedClientConn.Write(buf[0:nr])
if ew != nil || nr != nw {
break
}
}
if er != nil {
break
}
}
_ = stream.Close()
_ = wrappedClientConn.Close()
done <- struct{}{}
}()
<-done
}
// KillConn closes a specific connection by ID
func (t *Tunnel) KillConn(connID string) bool {
t.mu.Lock()
defer t.mu.Unlock()
if ac, ok := t.activeConns[connID]; ok {
_ = ac.Conn.Close()
delete(t.activeConns, connID)
return true
}
return false
}
// Close shuts down the listener and disconnects all clients
func (t *Tunnel) Close() {
t.closeOnce.Do(func() {
close(t.closed)
_ = t.listener.Close()
t.mu.Lock()
for _, ac := range t.activeConns {
_ = ac.Conn.Close()
}
t.activeConns = make(map[string]*ActiveClient)
t.mu.Unlock()
})
}
// Tick updates metrics
func (t *Tunnel) Tick() {
t.meter.Tick()
t.mu.RLock()
defer t.mu.RUnlock()
for _, ac := range t.activeConns {
ac.Meter.Tick()
}
}
// Metrics returns the current metrics snapshot
func (t *Tunnel) Metrics() (protocol.TunnelMetrics, []protocol.ActiveConnInfo) {
t.mu.RLock()
defer t.mu.RUnlock()
bIn, bOut, rIn, rOut := t.meter.Stats()
tm := protocol.TunnelMetrics{
TunnelID: t.Config.ID,
ListenPort: t.Config.ListenPort,
ActiveConns: len(t.activeConns),
TotalConns: t.totalConns.Load(),
BytesIn: bIn,
BytesOut: bOut,
RateIn: rIn,
RateOut: rOut,
BlockedAttemps: t.blockedAttemps.Load(),
Status: "online",
DynamicWhitelist: t.whitelist.GetDynamic(),
}
for ip, exp := range t.whitelist.DynamicLeases() {
tm.DynamicLeases = append(tm.DynamicLeases, protocol.DynamicLease{IP: ip, ExpiresAt: exp})
}
sort.Slice(tm.DynamicLeases, func(i, j int) bool {
return tm.DynamicLeases[i].ExpiresAt.Before(tm.DynamicLeases[j].ExpiresAt)
})
conns := make([]protocol.ActiveConnInfo, 0, len(t.activeConns))
for _, ac := range t.activeConns {
cbIn, cbOut, crIn, crOut := ac.Meter.Stats()
conns = append(conns, protocol.ActiveConnInfo{
ConnID: ac.ID,
TunnelID: t.Config.ID,
ClientIP: ac.ClientIP,
ConnectedAt: ac.ConnectedAt,
BytesIn: cbIn,
BytesOut: cbOut,
RateIn: crIn,
RateOut: crOut,
})
}
return tm, conns
}