PortRelay:把内网服务临时、可控地开放给同事

边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上;
通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。

- portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页)
- portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计)
- mac-native:SwiftUI 菜单栏 App
This commit is contained in:
DeepFal committed 2026-10-13 16:33:17 +08:00
commit 86e159b3f0
67 files changed
+13888

No files matched your search

+807
View File
@@ -0,0 +1,807 @@
package client
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/hashicorp/yamux"
"github.com/DeepFal/PortRelay/core/protocol"
)
const (
authRespTimeout = 10 * time.Second
reconnectDelay = 2 * time.Second
)
// Agent runs on Mac and connects to the relay server
type Agent struct {
cm *ConfigManager
mu sync.RWMutex
session *yamux.Session
connected bool
lastError string
latestMetrics protocol.MetricsMessage
regErrors map[string]string // tunnelID -> 服务器返回的注册失败原因
inviteWaiters map[string]chan protocol.InviteRespMessage
controlWriteMu sync.Mutex
controlWriter *bufio.Writer
// 闲置自动关闭与实际连接时长统计(按通道)
usageMu sync.Mutex
usage map[string]*usageState
idleUnit time.Duration // 闲置时长单位,正常为分钟(测试可调小)
rates *rateRecorder // 近 10 分钟流量曲线
metricsSub chan struct{}
closed chan struct{}
supervisorDone chan struct{}
stopOnce sync.Once
reconnectSignal chan struct{}
}
// NewAgent initializes the agent
func NewAgent(cm *ConfigManager) *Agent {
return &Agent{
cm: cm,
regErrors: make(map[string]string),
inviteWaiters: make(map[string]chan protocol.InviteRespMessage),
usage: make(map[string]*usageState),
idleUnit: time.Minute,
rates: newRateRecorder(),
metricsSub: make(chan struct{}, 1),
closed: make(chan struct{}),
supervisorDone: make(chan struct{}),
reconnectSignal: make(chan struct{}, 1),
}
}
// Reconnect drops the current session and reconnects immediately.
// force=false 时先探测现有会话,健康则保留(避免屏幕唤醒等场景无谓地切断所有连接)。
func (a *Agent) Reconnect(force bool) {
a.mu.RLock()
sess := a.session
a.mu.RUnlock()
if !force && sess != nil {
if healthy(sess) {
log.Printf("[Agent] 会话健康,无需重连")
return
}
}
log.Printf("[Agent] ⚡ 触发主动重连...")
if sess != nil {
_ = sess.Close()
}
select {
case a.reconnectSignal <- struct{}{}:
default:
}
}
func healthy(sess *yamux.Session) bool {
res := make(chan error, 1)
go func() {
_, err := sess.Ping()
res <- err
}()
select {
case err := <-res:
return err == nil
case <-time.After(3 * time.Second):
return false
}
}
// Start runs background connection supervisor and TTL checker
func (a *Agent) Start() {
go a.supervisorLoop()
go a.ttlCheckerLoop()
}
// Stop shuts down and waits (briefly) for the session to wind down, so the final
// traffic merge is in the config before the caller persists it. Safe to call more than once.
func (a *Agent) Stop() {
a.stopOnce.Do(func() {
close(a.closed)
a.mu.Lock()
if a.session != nil {
_ = a.session.Close()
}
a.mu.Unlock()
select {
case <-a.supervisorDone:
case <-time.After(3 * time.Second):
}
})
}
func (a *Agent) ttlCheckerLoop() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-a.closed:
return
case <-ticker.C:
for _, t := range a.cm.ExpireDue(a.ConsumeSessionBytes) {
log.Printf("[Agent] ⏰ 通道「%s」(端口 %d) %s,已自动关闭", t.Name, t.ListenPort, t.CloseReason)
a.dropUsage(t.ID)
a.SyncTunnel(t, false)
}
a.trackUsage(time.Now())
}
}
}
// usageState tracks one enabled tunnel's activity during the current session
type usageState struct {
lastActive time.Time // 最近一次“在用”的时间(闲置计时起点)
lastTick time.Time // 上次统计时间
inUse bool // 当前是否在用
pendingConn time.Duration // 尚未落盘的实际连接时长
firstMarked bool
}
// TunnelActivity is the live usage view of an enabled tunnel
type TunnelActivity struct {
InUse bool `json:"in_use"`
IdleSince *time.Time `json:"idle_since,omitempty"`
AutoCloseAt *time.Time `json:"auto_close_at,omitempty"`
ConnectedSec int64 `json:"connected_sec"` // 本次开启后有连接在线的累计秒数
}
// inUse: 有在线连接、有效授权(含尚未首次连接的)、待审批申请或仍可使用的邀请
func tunnelInUse(m protocol.TunnelMetrics) bool {
if m.ActiveConns > 0 || len(m.DynamicLeases) > 0 || len(m.Pending) > 0 {
return true
}
for _, inv := range m.Invites {
if inv.MaxUses <= 0 || len(inv.Uses) < inv.MaxUses {
return true
}
}
return false
}
const connectedFlushInterval = 10 * time.Second
// trackUsage runs every second: updates idle timers and real connected time, and closes idle tunnels
func (a *Agent) trackUsage(now time.Time) {
cfg := a.cm.Get()
metrics := a.GetLatestMetrics()
connected := a.IsConnected()
byID := make(map[string]protocol.TunnelMetrics, len(metrics.Tunnels))
for _, m := range metrics.Tunnels {
byID[m.TunnelID] = m
}
type closing struct {
id, reason string
flush int64
}
var toClose []closing
var firstConn []string
flush := map[string]int64{}
a.usageMu.Lock()
enabled := map[string]bool{}
for _, t := range cfg.Tunnels {
if !t.Enabled {
continue
}
enabled[t.ID] = true
u := a.usage[t.ID]
if u == nil {
u = &usageState{lastActive: now, lastTick: now, firstMarked: t.FirstConnectedAt != nil}
a.usage[t.ID] = u
}
dt := now.Sub(u.lastTick)
u.lastTick = now
m, ok := byID[t.ID]
if !connected || !ok || m.Status != "online" {
// 未连接服务器或尚未收到指标:暂停闲置计时,避免把网络中断误判为闲置
u.lastActive = now
u.inUse = false
continue
}
if m.ActiveConns > 0 {
u.pendingConn += dt
if !u.firstMarked {
u.firstMarked = true
firstConn = append(firstConn, t.ID)
}
}
u.inUse = tunnelInUse(m)
if u.inUse {
u.lastActive = now
}
// 即将因最长开启时长关闭时先落盘,避免丢失最后一段连接时长
expiring := t.ExpiresAt != nil && !now.Before(t.ExpiresAt.Add(-time.Second))
if u.pendingConn >= connectedFlushInterval || (expiring && u.pendingConn >= time.Second) {
secs := int64(u.pendingConn / time.Second)
u.pendingConn -= time.Duration(secs) * time.Second
flush[t.ID] = secs
}
if idle := IdleCloseMinutes(t); idle > 0 && now.Sub(u.lastActive) >= time.Duration(idle)*a.idleUnit {
secs := int64(u.pendingConn / time.Second)
toClose = append(toClose, closing{t.ID, fmt.Sprintf("闲置 %s 自动关闭", formatMinutes(idle)), secs})
delete(a.usage, t.ID)
}
}
for id := range a.usage {
if !enabled[id] {
delete(a.usage, id)
}
}
a.usageMu.Unlock()
a.rates.retain(enabled)
for _, id := range firstConn {
a.cm.MarkFirstConnected(id, now)
}
for id, secs := range flush {
a.cm.AddConnectedSeconds(id, secs)
}
for _, c := range toClose {
a.cm.AddConnectedSeconds(c.id, c.flush)
if t, ok := a.cm.CloseTunnel(c.id, c.reason, a.ConsumeSessionBytes); ok {
log.Printf("[Agent] 💤 通道「%s」(端口 %d) %s", t.Name, t.ListenPort, c.reason)
a.SyncTunnel(t, false)
}
}
}
// FlushUsage persists the not-yet-saved connected time of a tunnel (before it is stopped)
func (a *Agent) FlushUsage(id string) {
a.usageMu.Lock()
var secs int64
if u := a.usage[id]; u != nil {
secs = int64(u.pendingConn / time.Second)
u.pendingConn -= time.Duration(secs) * time.Second
}
a.usageMu.Unlock()
a.cm.AddConnectedSeconds(id, secs)
}
func (a *Agent) dropUsage(id string) {
a.usageMu.Lock()
delete(a.usage, id)
a.usageMu.Unlock()
}
// RateSeries returns the last 10 minutes of a tunnel's rates (id "" = all tunnels)
func (a *Agent) RateSeries(id string) RateSeries {
if id == "" {
id = totalSeries
}
return a.rates.series(id, time.Now())
}
// Activity returns the live usage of an enabled tunnel (nil when not tracked yet)
func (a *Agent) Activity(t protocol.TunnelConfig) *TunnelActivity {
a.usageMu.Lock()
defer a.usageMu.Unlock()
u := a.usage[t.ID]
if u == nil || !t.Enabled {
return nil
}
act := &TunnelActivity{InUse: u.inUse, ConnectedSec: t.ConnectedSec + int64(u.pendingConn/time.Second)}
if !u.inUse {
since := u.lastActive
act.IdleSince = &since
if idle := IdleCloseMinutes(t); idle > 0 {
at := since.Add(time.Duration(idle) * a.idleUnit)
act.AutoCloseAt = &at
}
}
return act
}
func (a *Agent) supervisorLoop() {
defer close(a.supervisorDone)
for {
select {
case <-a.closed:
return
default:
}
cfg := a.cm.Get()
if cfg.ServerAddr == "" {
// 首次运行尚未配置服务器:等用户在设置中填写后再连接
a.mu.Lock()
a.lastError = "尚未配置服务器地址,请在设置中填写"
a.mu.Unlock()
select {
case <-a.closed:
return
case <-a.reconnectSignal:
case <-time.After(reconnectDelay):
}
continue
}
log.Printf("[Agent] 正在连接中继服务器 (%s)...", cfg.ServerAddr)
err := a.connectAndRun(cfg)
if err != nil {
log.Printf("[Agent] 连接中断或失败: %v, %v 后尝试重连...", err, reconnectDelay)
}
a.resetSessionState(err)
select {
case <-a.closed:
return
case <-a.reconnectSignal:
log.Printf("[Agent] 立即执行重连...")
case <-time.After(reconnectDelay):
}
}
}
// resetSessionState 会话结束:把本次会话流量并入历史(服务端会重置计数),清掉过期指标
func (a *Agent) resetSessionState(err error) {
a.controlWriteMu.Lock()
a.controlWriter = nil
a.controlWriteMu.Unlock()
// 锁顺序固定为 cm.mu -> a.mu(ExpireDue 持 cm.mu 回调 ConsumeSessionBytes),这里先读配置再加锁
enabled := make(map[string]bool)
for _, t := range a.cm.Get().Tunnels {
enabled[t.ID] = t.Enabled
}
a.mu.Lock()
carry := make(map[string][2]int64)
for _, m := range a.latestMetrics.Tunnels {
if enabled[m.TunnelID] {
carry[m.TunnelID] = [2]int64{m.BytesIn, m.BytesOut}
}
}
a.connected = false
a.session = nil
a.latestMetrics = protocol.MetricsMessage{}
a.regErrors = make(map[string]string)
if err != nil && !errors.Is(err, io.EOF) {
a.lastError = err.Error()
} else {
a.lastError = "与中继服务器的连接已断开"
}
a.mu.Unlock()
a.cm.AddHistoricalTraffic(carry)
}
func (a *Agent) connectAndRun(cfg Config) error {
rawConn, err := net.DialTimeout("tcp", cfg.ServerAddr, 5*time.Second)
if err != nil {
return fmt.Errorf("dial server failed: %w", err)
}
defer rawConn.Close()
yCfg := yamux.DefaultConfig()
yCfg.EnableKeepAlive = true
yCfg.KeepAliveInterval = 10 * time.Second
yCfg.LogOutput = io.Discard
session, err := yamux.Client(rawConn, yCfg)
if err != nil {
return fmt.Errorf("yamux client init error: %w", err)
}
defer session.Close()
a.mu.Lock()
a.session = session
a.mu.Unlock()
controlStream, err := session.OpenStream()
if err != nil {
return fmt.Errorf("open control stream failed: %w", err)
}
defer controlStream.Close()
writer := bufio.NewWriter(controlStream)
reader := bufio.NewReader(controlStream)
// 1. Authenticate and wait for the verdict before declaring the link usable
if err := writeJSONLine(writer, protocol.AuthMessage{Type: protocol.TypeAuth, Token: cfg.Token}); err != nil {
return err
}
_ = controlStream.SetReadDeadline(time.Now().Add(authRespTimeout))
line, err := reader.ReadBytes('\n')
if err != nil {
return fmt.Errorf("等待鉴权结果失败: %w", err)
}
_ = controlStream.SetReadDeadline(time.Time{})
var authResp protocol.AuthRespMessage
if err := json.Unmarshal(line, &authResp); err != nil || authResp.Type != protocol.TypeAuthResp {
return fmt.Errorf("服务端返回了非预期的鉴权响应")
}
if !authResp.Success {
return fmt.Errorf("身份鉴权失败: %s", authResp.Error)
}
a.controlWriteMu.Lock()
a.controlWriter = writer
a.controlWriteMu.Unlock()
a.mu.Lock()
a.connected = true
a.lastError = ""
a.mu.Unlock()
log.Printf("[Agent] 成功与中继服务器握手并通过鉴权!")
// 2. Register all enabled tunnels
for _, t := range a.cm.Get().Tunnels {
if t.Enabled {
log.Printf("[Agent] 注册通道: %s (:%d -> %s) [责任人: %s]", t.Name, t.ListenPort, t.Target, t.Owner)
_ = a.sendControl(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: t})
}
}
// 3. Start data stream acceptor loop
go a.acceptDataStreams(session)
// 4. Read incoming control messages
for {
line, err := reader.ReadBytes('\n')
if err != nil {
if err != io.EOF {
log.Printf("[Agent] control stream read error: %v", err)
}
return err
}
if len(line) == 0 {
continue
}
var base protocol.BaseMessage
if err := json.Unmarshal(line, &base); err != nil {
continue
}
switch base.Type {
case protocol.TypeMetrics:
var msg protocol.MetricsMessage
if err := json.Unmarshal(line, &msg); err == nil {
a.storeMetrics(msg)
}
case protocol.TypeInviteResp:
var resp protocol.InviteRespMessage
if json.Unmarshal(line, &resp) != nil {
continue
}
a.mu.Lock()
if ch, ok := a.inviteWaiters[resp.RequestID]; ok {
ch <- resp
delete(a.inviteWaiters, resp.RequestID)
}
a.mu.Unlock()
case protocol.TypeRegisterResp:
var resp protocol.RegisterRespMessage
if json.Unmarshal(line, &resp) != nil {
continue
}
a.mu.Lock()
if resp.Success {
delete(a.regErrors, resp.TunnelID)
} else {
a.regErrors[resp.TunnelID] = resp.Error
log.Printf("[Agent] 通道 %s 注册失败: %s", resp.TunnelID, resp.Error)
}
a.mu.Unlock()
}
}
}
// storeMetrics 只保留当前处于开启状态的通道,避免已关闭通道的旧指标被重复计入历史流量
func (a *Agent) storeMetrics(msg protocol.MetricsMessage) {
enabled := make(map[string]bool)
for _, t := range a.cm.Get().Tunnels {
if t.Enabled {
enabled[t.ID] = true
}
}
filtered := msg.Tunnels[:0]
for _, m := range msg.Tunnels {
if enabled[m.TunnelID] {
filtered = append(filtered, m)
}
}
msg.Tunnels = filtered
rates := make(map[string][2]int64, len(msg.Tunnels))
for _, m := range msg.Tunnels {
rates[m.TunnelID] = [2]int64{m.RateIn, m.RateOut}
}
a.rates.record(time.Now(), rates)
a.mu.Lock()
a.latestMetrics = msg
a.mu.Unlock()
select {
case a.metricsSub <- struct{}{}:
default:
}
}
// ConsumeSessionBytes returns the current session traffic of a tunnel and drops it from the
// cached metrics so it cannot be merged into history twice.
func (a *Agent) ConsumeSessionBytes(id string) (int64, int64) {
a.mu.Lock()
defer a.mu.Unlock()
tunnels := a.latestMetrics.Tunnels
for i, m := range tunnels {
if m.TunnelID == id {
out := make([]protocol.TunnelMetrics, 0, len(tunnels)-1)
out = append(out, tunnels[:i]...)
out = append(out, tunnels[i+1:]...)
a.latestMetrics.Tunnels = out
return m.BytesIn, m.BytesOut
}
}
return 0, 0
}
func (a *Agent) acceptDataStreams(session *yamux.Session) {
for {
stream, err := session.AcceptStream()
if err != nil {
return
}
go a.handleDataStream(stream)
}
}
func (a *Agent) handleDataStream(stream net.Conn) {
defer stream.Close()
_ = stream.SetReadDeadline(time.Now().Add(10 * time.Second))
reader := bufio.NewReader(stream)
line, err := reader.ReadBytes('\n')
if err != nil {
return
}
_ = stream.SetReadDeadline(time.Time{})
var hs protocol.StreamHandshake
if err := json.Unmarshal(line, &hs); err != nil {
return
}
t, ok := a.cm.FindTunnel(hs.TunnelID)
if !ok || !t.Enabled {
return
}
connStartTime := time.Now()
targetConn, err := net.DialTimeout("tcp", t.Target, 3*time.Second)
if err != nil {
log.Printf("[Agent] 目标主机 %s 无法连接 (客户端 %s): %v", t.Target, hs.ClientIP, err)
return
}
defer targetConn.Close()
// Count bytes for connection history
var bytesIn, bytesOut atomic.Int64
done := make(chan struct{}, 2)
go func() {
buf := make([]byte, 32*1024)
for {
nr, er := reader.Read(buf)
if nr > 0 {
bytesIn.Add(int64(nr))
nw, ew := targetConn.Write(buf[:nr])
if ew != nil || nr != nw {
break
}
}
if er != nil {
break
}
}
_ = targetConn.Close()
_ = stream.Close()
done <- struct{}{}
}()
go func() {
buf := make([]byte, 32*1024)
for {
nr, er := targetConn.Read(buf)
if nr > 0 {
bytesOut.Add(int64(nr))
nw, ew := stream.Write(buf[:nr])
if ew != nil || nr != nw {
break
}
}
if er != nil {
break
}
}
_ = targetConn.Close()
_ = stream.Close()
done <- struct{}{}
}()
<-done
<-done
// Record client connection history
connEndTime := time.Now()
a.cm.AddClientHistory(protocol.ClientHistoryRecord{
ID: uuid.New().String(),
TunnelID: hs.TunnelID,
TunnelName: t.Name,
Port: t.ListenPort,
ClientIP: hs.ClientIP,
ConnectedAt: connStartTime,
DisconnectedAt: connEndTime,
DurationSec: int64(connEndTime.Sub(connStartTime).Seconds()),
BytesIn: bytesIn.Load(),
BytesOut: bytesOut.Load(),
})
}
func writeJSONLine(w *bufio.Writer, v any) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
data = append(data, '\n')
if _, err := w.Write(data); err != nil {
return err
}
return w.Flush()
}
func (a *Agent) sendControl(v any) error {
a.controlWriteMu.Lock()
defer a.controlWriteMu.Unlock()
if a.controlWriter == nil {
return fmt.Errorf("control stream not connected")
}
return writeJSONLine(a.controlWriter, v)
}
// SyncTunnel pushes a tunnel's current state to the relay server.
// edited=true 时使用 update_tunnel(端口不变则原地更新,不断开现有连接)。
func (a *Agent) SyncTunnel(t protocol.TunnelConfig, edited bool) {
if !t.Enabled {
a.mu.Lock()
delete(a.regErrors, t.ID)
a.mu.Unlock()
a.RemoveTunnel(t.ID)
return
}
if !a.IsConnected() {
return
}
if edited {
_ = a.sendControl(protocol.UpdateTunnelMessage{Type: protocol.TypeUpdateTunnel, Tunnel: t})
} else {
_ = a.sendControl(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: t})
}
}
func (a *Agent) RemoveTunnel(id string) {
a.mu.Lock()
delete(a.regErrors, id)
a.mu.Unlock()
if !a.IsConnected() {
return
}
_ = a.sendControl(protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: id})
}
// CreateInvite asks the relay server for a new invite link and waits for the result
func (a *Agent) CreateInvite(tunnelID, note string, validMinutes, maxUses int, leaseDuration string, requireApproval bool) (protocol.Invite, error) {
if !a.IsConnected() {
return protocol.Invite{}, fmt.Errorf("未连接中继服务器")
}
reqID := uuid.New().String()
ch := make(chan protocol.InviteRespMessage, 1)
a.mu.Lock()
a.inviteWaiters[reqID] = ch
a.mu.Unlock()
defer func() {
a.mu.Lock()
delete(a.inviteWaiters, reqID)
a.mu.Unlock()
}()
if err := a.sendControl(protocol.CreateInviteMessage{
Type: protocol.TypeCreateInvite, RequestID: reqID, TunnelID: tunnelID,
Note: note, ValidMinutes: validMinutes, MaxUses: maxUses, LeaseDuration: leaseDuration, RequireApproval: requireApproval,
}); err != nil {
return protocol.Invite{}, err
}
select {
case resp := <-ch:
if !resp.Success {
return protocol.Invite{}, fmt.Errorf("%s", resp.Error)
}
return resp.Invite, nil
case <-time.After(5 * time.Second):
return protocol.Invite{}, fmt.Errorf("服务器未响应,请检查是否已升级到支持邀请链接的版本")
}
}
// RevokeInvite invalidates an invite link and the leases it granted
func (a *Agent) RevokeInvite(tunnelID, code string) error {
if !a.IsConnected() {
return fmt.Errorf("未连接中继服务器")
}
return a.sendControl(protocol.RevokeInviteMessage{Type: protocol.TypeRevokeInvite, TunnelID: tunnelID, Code: code})
}
// DecideRequest approves or rejects a pending access request
func (a *Agent) DecideRequest(tunnelID, requestID string, approve bool) error {
if !a.IsConnected() {
return fmt.Errorf("未连接中继服务器")
}
return a.sendControl(protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, TunnelID: tunnelID, RequestID: requestID, Approve: approve})
}
// RevokeLease removes a temporary whitelist lease on the relay server (all leases when ip is empty)
func (a *Agent) RevokeLease(tunnelID, ip string) error {
if !a.IsConnected() {
return fmt.Errorf("未连接中继服务器")
}
return a.sendControl(protocol.RevokeLeaseMessage{Type: protocol.TypeRevokeLease, TunnelID: tunnelID, IP: ip})
}
func (a *Agent) KillConn(connID string) {
if !a.IsConnected() {
return
}
_ = a.sendControl(protocol.KillConnMessage{Type: protocol.TypeKillConn, ConnID: connID})
}
func (a *Agent) IsConnected() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.connected
}
// LastError returns why the agent is currently not connected (empty when connected)
func (a *Agent) LastError() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.lastError
}
// TunnelError returns the last registration error reported by the relay server for a tunnel
func (a *Agent) TunnelError(id string) string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.regErrors[id]
}
func (a *Agent) GetLatestMetrics() protocol.MetricsMessage {
a.mu.RLock()
defer a.mu.RUnlock()
m := a.latestMetrics
m.Tunnels = append([]protocol.TunnelMetrics(nil), m.Tunnels...)
m.ActiveConns = append([]protocol.ActiveConnInfo(nil), m.ActiveConns...)
m.SecurityEvents = append([]protocol.SecurityEvent(nil), m.SecurityEvents...)
return m
}
+647
View File
@@ -0,0 +1,647 @@
package client
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/DeepFal/PortRelay/core/protocol"
)
const maxBodyBytes = 64 * 1024
// APIServer provides REST and SSE endpoints for the dashboard
type APIServer struct {
cm *ConfigManager
agent *Agent
fs http.FileSystem
// OnShutdown 由 POST /api/shutdown 触发(菜单栏“退出”时彻底结束后台引擎)
OnShutdown func()
}
// NewAPIServer creates an API server
func NewAPIServer(cm *ConfigManager, agent *Agent, fs http.FileSystem) *APIServer {
return &APIServer{
cm: cm,
agent: agent,
fs: fs,
}
}
// Handler returns the http handler with routing
func (s *APIServer) Handler() http.Handler {
mux := http.NewServeMux()
// API endpoints
mux.HandleFunc("/api/status", s.handleStatus)
mux.HandleFunc("/api/tunnels", s.handleTunnels)
mux.HandleFunc("/api/tunnels/", s.handleTunnelItem)
mux.HandleFunc("/api/probe", s.handleProbe)
mux.HandleFunc("/api/recommend-port", s.handleRecommendPort)
mux.HandleFunc("/api/connections", s.handleConnections)
mux.HandleFunc("/api/connections/kill", s.handleKillConn)
mux.HandleFunc("/api/history", s.handleClientHistory)
mux.HandleFunc("/api/reconnect", s.handleReconnect)
mux.HandleFunc("/api/settings", s.handleSettings)
mux.HandleFunc("/api/stream", s.handleSSE)
mux.HandleFunc("/api/shutdown", s.handleShutdown)
// Static web files
if s.fs != nil {
fileServer := http.FileServer(s.fs)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api") {
http.NotFound(w, r)
return
}
fileServer.ServeHTTP(w, r)
})
}
return localOnlyMiddleware(mux)
}
func isLoopbackHost(hostport string) bool {
host := hostport
if h, _, err := net.SplitHostPort(hostport); err == nil {
host = h
}
host = strings.Trim(host, "[]")
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// localOnlyMiddleware 只接受本机面板与原生 App 的请求:
// - Host 必须是回环地址(防 DNS rebinding)
// - 带 Origin 的请求必须同源(防任意网页通过浏览器跨站调用本地 API)
func localOnlyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isLoopbackHost(r.Host) {
http.Error(w, "forbidden host", http.StatusForbidden)
return
}
if origin := r.Header.Get("Origin"); origin != "" {
u, err := url.Parse(origin)
if err != nil || !strings.EqualFold(u.Host, r.Host) {
http.Error(w, "cross-origin request rejected", http.StatusForbidden)
return
}
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
}
next.ServeHTTP(w, r)
})
}
func (s *APIServer) statusPayload() map[string]any {
cfg := s.cm.Get()
return map[string]any{
"connected": s.agent.IsConnected(),
"server_addr": cfg.ServerAddr,
"public_host": cfg.PublicHostOrDefault(),
"last_error": s.agent.LastError(),
"invite_base": s.inviteBase(cfg),
"total_rate_history": s.agent.RateSeries(""),
"port_range": fmt.Sprintf("%d - %d", PortMin, PortMax),
"timestamp": time.Now(),
}
}
func (s *APIServer) handleStatus(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, s.statusPayload())
}
// TunnelWithMetrics joins config with current metrics
type TunnelWithMetrics struct {
protocol.TunnelConfig
Metrics protocol.TunnelMetrics `json:"metrics"`
Activity *TunnelActivity `json:"activity,omitempty"`
// EffectiveIdleCloseMinutes 生效的闲置自动关闭时长(0 表示不自动关闭)
EffectiveIdleCloseMinutes int `json:"effective_idle_close_minutes"`
// RateHistory 近 10 分钟流量曲线(仅开启中的通道)
RateHistory *RateSeries `json:"rate_history,omitempty"`
}
// tunnelsWithMetrics merges config, live metrics and registration errors into one consistent view.
// status: disabled | offline (未连接服务器) | error (服务器拒绝注册) | syncing (等待指标) | online
func (s *APIServer) tunnelsWithMetrics(metrics protocol.MetricsMessage) []TunnelWithMetrics {
cfg := s.cm.Get()
connected := s.agent.IsConnected()
metricsMap := make(map[string]protocol.TunnelMetrics)
for _, m := range metrics.Tunnels {
metricsMap[m.TunnelID] = m
}
result := make([]TunnelWithMetrics, 0, len(cfg.Tunnels))
for _, t := range cfg.Tunnels {
tm := protocol.TunnelMetrics{TunnelID: t.ID, ListenPort: t.ListenPort}
switch {
case !t.Enabled:
tm.Status = "disabled"
case !connected:
tm.Status = "offline"
case s.agent.TunnelError(t.ID) != "":
tm.Status = "error"
tm.Error = s.agent.TunnelError(t.ID)
default:
if m, ok := metricsMap[t.ID]; ok {
tm = m
} else {
tm.Status = "syncing"
}
}
item := TunnelWithMetrics{
TunnelConfig: t,
Metrics: tm,
Activity: s.agent.Activity(t),
EffectiveIdleCloseMinutes: IdleCloseMinutes(t),
}
if t.Enabled {
series := s.agent.RateSeries(t.ID)
item.RateHistory = &series
}
result = append(result, item)
}
return result
}
func (s *APIServer) handleTunnels(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, s.tunnelsWithMetrics(s.agent.GetLatestMetrics()))
case http.MethodPost:
var t protocol.TunnelConfig
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON payload")
return
}
created, err := s.cm.AddTunnel(t)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
s.agent.SyncTunnel(created, false)
jsonResponse(w, http.StatusCreated, created)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (s *APIServer) handleTunnelItem(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 3 || parts[2] == "" {
jsonError(w, http.StatusBadRequest, "Missing tunnel ID")
return
}
tunnelID := parts[2]
if len(parts) == 4 && parts[3] == "toggle" && r.Method == http.MethodPost {
s.handleToggle(w, r, tunnelID)
return
}
// POST /api/tunnels/{id}/leases/revoke {"ip": "..."};ip 为空表示撤销该通道全部临时放通
if len(parts) == 5 && parts[3] == "leases" && parts[4] == "revoke" && r.Method == http.MethodPost {
var req struct {
IP string `json:"ip"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if _, ok := s.cm.FindTunnel(tunnelID); !ok {
jsonError(w, http.StatusNotFound, "通道未找到")
return
}
if err := s.agent.RevokeLease(tunnelID, strings.TrimSpace(req.IP)); err != nil {
jsonError(w, http.StatusConflict, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
return
}
// 邀请链接:POST 新建;DELETE /invites/{code} 作废;GET /invites/{code}/share 获取分享文案
if len(parts) >= 4 && parts[3] == "invites" {
s.handleInvites(w, r, tunnelID, parts[4:])
return
}
// POST /api/tunnels/{id}/requests/{rid} {"approve": true|false}
if len(parts) == 5 && parts[3] == "requests" && r.Method == http.MethodPost {
var req struct {
Approve bool `json:"approve"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := s.agent.DecideRequest(tunnelID, parts[4], req.Approve); err != nil {
jsonError(w, http.StatusConflict, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "ok"})
return
}
if len(parts) == 4 && parts[3] == "refresh-auth-code" && r.Method == http.MethodPost {
t, err := s.cm.RefreshAuthCode(tunnelID)
if err != nil {
jsonError(w, http.StatusNotFound, err.Error())
return
}
s.agent.SyncTunnel(t, true)
jsonResponse(w, http.StatusOK, t)
return
}
if len(parts) != 3 {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodPut:
body, err := io.ReadAll(r.Body)
if err != nil {
jsonError(w, http.StatusBadRequest, "Invalid body")
return
}
updated, _, err := s.cm.PatchTunnel(tunnelID, body)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
s.agent.SyncTunnel(updated, true)
jsonResponse(w, http.StatusOK, updated)
case http.MethodDelete:
if err := s.cm.DeleteTunnel(tunnelID); err != nil {
jsonError(w, http.StatusNotFound, err.Error())
return
}
s.agent.ConsumeSessionBytes(tunnelID)
s.agent.RemoveTunnel(tunnelID)
jsonResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// handleToggle 支持 {"enabled": true|false} 指定目标状态(幂等,防止重复点击来回翻转);不带 body 时取反
func (s *APIServer) handleToggle(w http.ResponseWriter, r *http.Request, tunnelID string) {
var req struct {
Enabled *bool `json:"enabled"`
}
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
}
current, err := s.cm.CurrentEnabled(tunnelID)
if err != nil {
jsonError(w, http.StatusNotFound, err.Error())
return
}
want := !current
if req.Enabled != nil {
want = *req.Enabled
}
var sessIn, sessOut int64
if !want && current {
s.agent.FlushUsage(tunnelID)
}
if want != current {
// 关闭时把本次会话流量并入历史;开启时清掉可能残留的旧指标
sessIn, sessOut = s.agent.ConsumeSessionBytes(tunnelID)
}
t, changed, err := s.cm.SetTunnelEnabled(tunnelID, want, sessIn, sessOut)
if err != nil {
jsonError(w, http.StatusNotFound, err.Error())
return
}
if changed {
s.agent.dropUsage(tunnelID)
s.agent.SyncTunnel(t, false)
}
jsonResponse(w, http.StatusOK, t)
}
// inviteBase is the URL prefix of invite links: http://<对外访问地址>:<控制端口>/i/
func (s *APIServer) inviteBase(cfg Config) string {
port := "33000"
if _, p, err := net.SplitHostPort(cfg.ServerAddr); err == nil {
port = p
}
host := cfg.PublicHostOrDefault()
if host == "" {
return ""
}
return "http://" + net.JoinHostPort(host, port) + "/i/"
}
// shareText is the ready-to-send message (e.g. for WeChat) for an invite
func shareText(cfg Config, t protocol.TunnelConfig, inv protocol.Invite, link string) string {
var b strings.Builder
b.WriteString("【PortRelay 访问邀请】")
if inv.Note != "" {
b.WriteString("(给 " + inv.Note + ")")
}
b.WriteString("\n通道:" + t.Name)
b.WriteString(fmt.Sprintf("\n访问地址:%s:%d", cfg.PublicHostOrDefault(), t.ListenPort))
action := "填写姓名后即可访问"
if inv.RequireApproval {
action = "填写姓名提交申请,管理员批准后即可访问"
}
b.WriteString(fmt.Sprintf("\n请在 %s 前用浏览器打开下面的链接,%s(访问有效期:%s):\n%s",
inv.ExpiresAt.Local().Format("01-02 15:04"), action, protocol.DescribeLeaseDuration(inv.LeaseDuration), link))
if inv.MaxUses == 1 {
b.WriteString("\n此链接仅限 1 台设备使用,请勿转发。")
} else if inv.MaxUses > 1 {
b.WriteString(fmt.Sprintf("\n此链接最多 %d 台设备使用,请勿转发。", inv.MaxUses))
}
return b.String()
}
func (s *APIServer) handleInvites(w http.ResponseWriter, r *http.Request, tunnelID string, rest []string) {
t, ok := s.cm.FindTunnel(tunnelID)
if !ok {
jsonError(w, http.StatusNotFound, "通道未找到")
return
}
cfg := s.cm.Get()
switch {
case len(rest) == 0 && r.Method == http.MethodPost:
var req struct {
Note string `json:"note"`
ValidMinutes int `json:"valid_minutes"`
MaxUses *int `json:"max_uses"`
LeaseDuration string `json:"lease_duration"` // 授权后可访问时长,默认 2 小时
RequireApproval bool `json:"require_approval"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
// 默认:24 小时内有效、限 1 台设备
if req.ValidMinutes == 0 {
req.ValidMinutes = 24 * 60
}
maxUses := 1
if req.MaxUses != nil {
maxUses = *req.MaxUses
}
if !protocol.ValidLeaseDuration(req.LeaseDuration) {
jsonError(w, http.StatusBadRequest, "无效的授权时长")
return
}
if req.LeaseDuration == "" {
req.LeaseDuration = protocol.LeaseDuration2h
}
if !t.Enabled {
jsonError(w, http.StatusConflict, "通道未开启,请先开启通道再生成邀请链接")
return
}
inv, err := s.agent.CreateInvite(tunnelID, strings.TrimSpace(req.Note), req.ValidMinutes, maxUses, req.LeaseDuration, req.RequireApproval)
if err != nil {
jsonError(w, http.StatusConflict, err.Error())
return
}
link := s.inviteBase(cfg) + inv.Code
jsonResponse(w, http.StatusCreated, map[string]any{
"invite": inv,
"link": link,
"share_text": shareText(cfg, t, inv, link),
})
case len(rest) == 1 && r.Method == http.MethodDelete:
if err := s.agent.RevokeInvite(tunnelID, rest[0]); err != nil {
jsonError(w, http.StatusConflict, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
case len(rest) == 2 && rest[1] == "share" && r.Method == http.MethodGet:
for _, m := range s.agent.GetLatestMetrics().Tunnels {
if m.TunnelID != tunnelID {
continue
}
for _, inv := range m.Invites {
if inv.Code == rest[0] {
link := s.inviteBase(cfg) + inv.Code
jsonResponse(w, http.StatusOK, map[string]any{"invite": inv, "link": link, "share_text": shareText(cfg, t, inv, link)})
return
}
}
}
jsonError(w, http.StatusNotFound, "邀请链接不存在或已过期")
default:
http.NotFound(w, r)
}
}
func (s *APIServer) handleReconnect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
// mode=check:仅在会话不健康时重连(系统唤醒时使用)
force := r.URL.Query().Get("mode") != "check"
go s.agent.Reconnect(force)
jsonResponse(w, http.StatusOK, map[string]string{"status": "reconnecting"})
}
func (s *APIServer) handleProbe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req struct {
Target string `json:"target"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
req.Target = strings.TrimSpace(req.Target)
if req.Target == "" {
jsonError(w, http.StatusBadRequest, "target is required")
return
}
res := ProbeTarget(req.Target, 2*time.Second)
jsonResponse(w, http.StatusOK, map[string]any{
"target": res.Target,
"reachable": res.Reachable,
"rtt": res.FormatRTT(),
"error": res.Error,
})
}
func (s *APIServer) handleRecommendPort(w http.ResponseWriter, r *http.Request) {
port := s.cm.RecommendPort()
if port == 0 {
jsonError(w, http.StatusConflict, "端口池已无空闲端口")
return
}
jsonResponse(w, http.StatusOK, map[string]int{"recommended_port": port})
}
// handleConnections returns live client connections (optionally for one tunnel) and recent security events
func (s *APIServer) handleConnections(w http.ResponseWriter, r *http.Request) {
metrics := s.agent.GetLatestMetrics()
tunnelID := r.URL.Query().Get("tunnel_id")
conns := make([]protocol.ActiveConnInfo, 0, len(metrics.ActiveConns))
for _, c := range metrics.ActiveConns {
if tunnelID == "" || c.TunnelID == tunnelID {
conns = append(conns, c)
}
}
events := metrics.SecurityEvents
if events == nil {
events = []protocol.SecurityEvent{}
}
jsonResponse(w, http.StatusOK, map[string]any{
"active_conns": conns,
"security_events": events,
})
}
func (s *APIServer) handleKillConn(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req struct {
ConnID string `json:"conn_id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.ConnID != "" {
s.agent.KillConn(req.ConnID)
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *APIServer) handleClientHistory(w http.ResponseWriter, r *http.Request) {
tunnelID := r.URL.Query().Get("tunnel_id")
jsonResponse(w, http.StatusOK, s.cm.GetClientHistory(tunnelID))
}
func (s *APIServer) handleSettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cfg := s.cm.Get()
jsonResponse(w, http.StatusOK, map[string]string{
"server_addr": cfg.ServerAddr,
"token": cfg.Token,
"public_host": cfg.PublicHostOrDefault(),
})
case http.MethodPost:
var req struct {
ServerAddr string `json:"server_addr"`
Token string `json:"token"`
PublicHost *string `json:"public_host"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
publicHost := s.cm.Get().PublicHost
if req.PublicHost != nil {
publicHost = *req.PublicHost
}
changed, err := s.cm.UpdateServer(req.ServerAddr, req.Token, publicHost)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
// 新地址/令牌必须重连才会生效;只改对外访问地址时不打断连接
if changed {
go s.agent.Reconnect(true)
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "updated"})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (s *APIServer) handleSSE(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
send := func() bool {
metrics := s.agent.GetLatestMetrics()
payload := s.statusPayload()
payload["metrics"] = metrics
payload["tunnels"] = s.tunnelsWithMetrics(metrics)
data, _ := json.Marshal(payload)
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
return false
}
flusher.Flush()
return true
}
if !send() {
return
}
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case <-ticker.C:
if !send() {
return
}
}
}
}
func (s *APIServer) handleShutdown(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if s.OnShutdown == nil {
jsonError(w, http.StatusNotImplemented, "shutdown not supported")
return
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "shutting down"})
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
go s.OnShutdown()
}
func jsonResponse(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}
func jsonError(w http.ResponseWriter, status int, msg string) {
jsonResponse(w, status, map[string]string{"error": msg})
}
+712
View File
@@ -0,0 +1,712 @@
package client
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DeepFal/PortRelay/core/protocol"
"github.com/DeepFal/PortRelay/server"
)
const testToken = "secret"
type peekedConn struct {
net.Conn
r io.Reader
}
func (c *peekedConn) Read(b []byte) (int, error) { return c.r.Read(b) }
// startRelayServer runs an in-process relay server (same dispatch as cmd/portrelay-server)
func startRelayServer(t *testing.T) (string, *server.Manager) {
t.Helper()
m := server.NewManager(PortMin, PortMax, testToken)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
br := bufio.NewReader(c)
p, err := br.Peek(3)
if err != nil {
c.Close()
return
}
if s := string(p); s == "GET" || s == "POS" || s == "HEA" {
m.HandleHTTP(&peekedConn{c, br})
return
}
_ = m.HandleSession(&peekedConn{c, br})
}(c)
}
}()
return ln.Addr().String(), m
}
// startEcho runs a TCP echo server acting as the intranet target
func startEcho(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() { _, _ = io.Copy(c, c); c.Close() }()
}
}()
return ln.Addr().String()
}
// freeRelayPort finds a free port inside the tunnel range
func freeRelayPort(t *testing.T, exclude ...int) int {
t.Helper()
skip := map[int]bool{ControlPort: true}
for _, p := range exclude {
skip[p] = true
}
for p := 33900; p < PortMax; p++ {
if skip[p] {
continue
}
if ln, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", p)); err == nil {
ln.Close()
return p
}
}
t.Fatal("no free port in tunnel range")
return 0
}
func newTestConfig(t *testing.T, serverAddr, token string) *ConfigManager {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
data, _ := json.Marshal(Config{ServerAddr: serverAddr, Token: token, Tunnels: []protocol.TunnelConfig{}})
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
cm, err := NewConfigManagerAt(path)
if err != nil {
t.Fatal(err)
}
// 在临时目录删除前停止延迟写盘(Cleanup 按注册的逆序执行,Agent 会先停止)
t.Cleanup(func() { _ = cm.Close() })
return cm
}
type env struct {
cm *ConfigManager
agent *Agent
api *httptest.Server
addr string
}
func startEnv(t *testing.T, token string) *env {
t.Helper()
return startEnvWith(t, token, nil)
}
func startEnvWith(t *testing.T, token string, tweak func(*Agent)) *env {
t.Helper()
addr, _ := startRelayServer(t)
cm := newTestConfig(t, addr, token)
agent := NewAgent(cm)
if tweak != nil {
tweak(agent)
}
agent.Start()
t.Cleanup(agent.Stop)
api := httptest.NewServer(NewAPIServer(cm, agent, nil).Handler())
t.Cleanup(api.Close)
return &env{cm: cm, agent: agent, api: api, addr: addr}
}
func (e *env) call(t *testing.T, method, path string, body any) (int, []byte) {
t.Helper()
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = strings.NewReader(string(b))
}
req, _ := http.NewRequest(method, e.api.URL+path, r)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
return resp.StatusCode, out
}
func (e *env) tunnel(t *testing.T, id string) TunnelWithMetrics {
t.Helper()
_, body := e.call(t, "GET", "/api/tunnels", nil)
var list []TunnelWithMetrics
_ = json.Unmarshal(body, &list)
for _, x := range list {
if x.ID == id {
return x
}
}
t.Fatalf("tunnel %s not found", id)
return TunnelWithMetrics{}
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("timed out waiting for: %s", what)
}
func echoVia(port int, msg string) (string, error) {
c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second)
if err != nil {
return "", err
}
defer c.Close()
_ = c.SetDeadline(time.Now().Add(2 * time.Second))
if _, err := c.Write([]byte(msg)); err != nil {
return "", err
}
buf := make([]byte, len(msg))
_, err = io.ReadFull(c, buf)
return string(buf), err
}
func TestEndToEndEditKeepsStateAndConnections(t *testing.T) {
e := startEnv(t, testToken)
waitFor(t, "agent connected", e.agent.IsConnected)
target := startEcho(t)
port := freeRelayPort(t)
status, body := e.call(t, "POST", "/api/tunnels", map[string]any{
"name": "db", "target": target, "listen_port": port, "owner": "ops",
"duration_minutes": 0, "max_bandwidth_mb": 5, "whitelist": []string{"*"},
})
if status != http.StatusCreated {
t.Fatalf("create: %d %s", status, body)
}
var created protocol.TunnelConfig
_ = json.Unmarshal(body, &created)
waitFor(t, "tunnel online", func() bool { return e.tunnel(t, created.ID).Metrics.Status == "online" })
if got, err := echoVia(port, "hello"); err != nil || got != "hello" {
t.Fatalf("relay broken: %q %v", got, err)
}
// 编辑前生成一个邀请链接,编辑后应仍然有效
status, body = e.call(t, "POST", "/api/tunnels/"+created.ID+"/invites", map[string]any{"max_uses": 0})
if status != http.StatusCreated {
t.Fatalf("create invite: %d %s", status, body)
}
var invResp struct {
Link string `json:"link"`
}
_ = json.Unmarshal(body, &invResp)
// 打开一条长连接,编辑后应保持可用
long, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
t.Fatal(err)
}
defer long.Close()
// Web 端只提交部分字段
status, body = e.call(t, "PUT", "/api/tunnels/"+created.ID, map[string]any{
"name": "db-renamed", "target": target, "listen_port": port, "whitelist": []string{"*"}, "enabled": true,
})
if status != http.StatusOK {
t.Fatalf("edit: %d %s", status, body)
}
var edited protocol.TunnelConfig
_ = json.Unmarshal(body, &edited)
if edited.Owner != "ops" || edited.MaxBandwidthMB != 5 {
t.Fatalf("partial edit wiped other fields: owner=%q bw=%v", edited.Owner, edited.MaxBandwidthMB)
}
_ = long.SetDeadline(time.Now().Add(2 * time.Second))
if _, err := long.Write([]byte("ping")); err != nil {
t.Fatal(err)
}
buf := make([]byte, 4)
if _, err := io.ReadFull(long, buf); err != nil || string(buf) != "ping" {
t.Fatalf("existing connection dropped by metadata edit: %v", err)
}
// 编辑后邀请链接仍然有效
resp, err := http.Get(invResp.Link)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("invite link rejected after edit: %d", resp.StatusCode)
}
// 关闭后再编辑,不能被重新打开
e.call(t, "POST", "/api/tunnels/"+created.ID+"/toggle", map[string]bool{"enabled": false})
e.call(t, "PUT", "/api/tunnels/"+created.ID, map[string]any{"name": "x", "enabled": true})
if got := e.tunnel(t, created.ID); got.Enabled || got.Metrics.Status != "disabled" {
t.Fatalf("editing a disabled tunnel re-enabled it: enabled=%v status=%s", got.Enabled, got.Metrics.Status)
}
// 幂等开关:重复提交 enabled=false 不会翻转
e.call(t, "POST", "/api/tunnels/"+created.ID+"/toggle", map[string]bool{"enabled": false})
if e.tunnel(t, created.ID).Enabled {
t.Fatal("toggle with explicit state is not idempotent")
}
}
func TestWrongTokenIsReportedAsDisconnected(t *testing.T) {
e := startEnv(t, "WRONG")
waitFor(t, "auth error surfaced", func() bool { return strings.Contains(e.agent.LastError(), "鉴权") })
if e.agent.IsConnected() {
t.Fatal("agent reports connected with a wrong token")
}
// 改成正确 token 后应自动重连
status, body := e.call(t, "POST", "/api/settings", map[string]string{"server_addr": e.addr, "token": testToken})
if status != http.StatusOK {
t.Fatalf("settings: %d %s", status, body)
}
waitFor(t, "reconnected after settings change", e.agent.IsConnected)
if e.agent.LastError() != "" {
t.Fatalf("last error not cleared: %s", e.agent.LastError())
}
}
func TestRegistrationErrorIsVisible(t *testing.T) {
e := startEnv(t, testToken)
waitFor(t, "agent connected", e.agent.IsConnected)
port := freeRelayPort(t)
blocker, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port))
if err != nil {
t.Fatal(err)
}
defer blocker.Close()
_, body := e.call(t, "POST", "/api/tunnels", map[string]any{"name": "x", "target": "127.0.0.1:1", "listen_port": port})
var created protocol.TunnelConfig
_ = json.Unmarshal(body, &created)
waitFor(t, "error status", func() bool {
m := e.tunnel(t, created.ID).Metrics
return m.Status == "error" && strings.Contains(m.Error, "bind")
})
}
func TestAPIRejectsCrossOriginAndForeignHost(t *testing.T) {
cm := newTestConfig(t, "127.0.0.1:1", testToken)
h := NewAPIServer(cm, NewAgent(cm), nil).Handler()
cases := []struct {
name, host, origin string
want int
}{
{"same origin", "127.0.0.1:33080", "http://127.0.0.1:33080", 200},
{"native app (no origin)", "127.0.0.1:33080", "", 200},
{"localhost", "localhost:33080", "", 200},
{"evil site", "127.0.0.1:33080", "https://evil.example", 403},
{"dns rebinding", "evil.example:33080", "", 403},
}
for _, c := range cases {
req := httptest.NewRequest("GET", "/api/settings", nil)
req.Host = c.host
if c.origin != "" {
req.Header.Set("Origin", c.origin)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != c.want {
t.Errorf("%s: got %d want %d", c.name, rec.Code, c.want)
}
if rec.Header().Get("Access-Control-Allow-Origin") != "" {
t.Errorf("%s: CORS header must not be set", c.name)
}
}
}
func TestCorruptConfigIsBackedUp(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"tunnels": [`), 0600); err != nil {
t.Fatal(err)
}
if _, err := NewConfigManagerAt(path); err != nil {
t.Fatal(err)
}
matches, _ := filepath.Glob(path + ".corrupt-*")
if len(matches) != 1 {
t.Fatalf("expected a backup of the corrupt config, got %v", matches)
}
if b, _ := os.ReadFile(matches[0]); string(b) != `{"tunnels": [` {
t.Fatal("backup content differs from original")
}
}
func TestPatchValidationAndTTL(t *testing.T) {
cm := newTestConfig(t, "127.0.0.1:1", testToken)
a, err := cm.AddTunnel(protocol.TunnelConfig{Name: "a", Target: "10.0.0.1:22", ListenPort: 33701, DurationMinutes: 60})
if err != nil {
t.Fatal(err)
}
if _, err := cm.AddTunnel(protocol.TunnelConfig{Name: "b", Target: "10.0.0.1:22", ListenPort: 33701}); err == nil {
t.Fatal("duplicate port accepted")
}
for _, bad := range []string{
`{"listen_port": 33000}`,
`{"listen_port": 40000}`,
`{"target": "10.0.0.1"}`,
`{"whitelist": ["192.168.1.*"]}`,
} {
if _, _, err := cm.PatchTunnel(a.ID, []byte(bad)); err == nil {
t.Errorf("patch %s accepted", bad)
}
}
// 改成不限时:到期时间应清除
upd, _, err := cm.PatchTunnel(a.ID, []byte(`{"duration_minutes": 0}`))
if err != nil {
t.Fatal(err)
}
if upd.ExpiresAt != nil {
t.Fatal("expires_at kept after switching to unlimited")
}
// 受保护字段不能被编辑
upd, _, _ = cm.PatchTunnel(a.ID, []byte(`{"enabled": false, "auth_code": "hacked", "historical_bytes_in": 999}`))
if !upd.Enabled || upd.AuthCode != a.AuthCode || upd.HistoricalBytesIn != 0 {
t.Fatalf("protected fields modified: %+v", upd)
}
}
func TestGetReturnsDeepCopy(t *testing.T) {
cm := newTestConfig(t, "127.0.0.1:1", testToken)
if _, err := cm.AddTunnel(protocol.TunnelConfig{Name: "a", Target: "10.0.0.1:22", ListenPort: 33702, Whitelist: []string{"*"}}); err != nil {
t.Fatal(err)
}
snap := cm.Get()
snap.Tunnels[0].Name = "mutated"
snap.Tunnels[0].Whitelist[0] = "1.1.1.1"
if got := cm.Get().Tunnels[0]; got.Name != "a" || got.Whitelist[0] != "*" {
t.Fatal("Get() leaks internal state")
}
}
func TestFirstRunHasNoUserData(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
cm, err := NewConfigManagerAt(path)
if err != nil {
t.Fatal(err)
}
cfg := cm.Get()
if cfg.ServerAddr != "" || cfg.Token != "" || len(cfg.Tunnels) != 0 {
t.Fatalf("first-run config must be empty, got addr=%q token=%q tunnels=%d", cfg.ServerAddr, cfg.Token, len(cfg.Tunnels))
}
agent := NewAgent(cm)
agent.Start()
defer agent.Stop()
waitFor(t, "not-configured hint", func() bool { return strings.Contains(agent.LastError(), "尚未配置") })
if agent.IsConnected() {
t.Fatal("agent connected without a configured server")
}
// 未单独设置对外访问地址时,取服务器地址的主机部分
if _, err := cm.UpdateServer("10.1.2.3:33000", "t", ""); err != nil {
t.Fatal(err)
}
if got := cm.Get().PublicHostOrDefault(); got != "10.1.2.3" {
t.Fatalf("public host fallback = %q", got)
}
}
func TestLeaseListAndRevoke(t *testing.T) {
e := startEnv(t, testToken)
waitFor(t, "agent connected", e.agent.IsConnected)
target := startEcho(t)
port := freeRelayPort(t)
// 固定白名单不包含 127.0.0.1,只有临时放通后才能访问
_, body := e.call(t, "POST", "/api/tunnels", map[string]any{
"name": "lease", "target": target, "listen_port": port, "whitelist": []string{"10.9.9.9"},
})
var created protocol.TunnelConfig
_ = json.Unmarshal(body, &created)
waitFor(t, "tunnel online", func() bool { return e.tunnel(t, created.ID).Metrics.Status == "online" })
if _, err := echoVia(port, "x"); err == nil {
t.Fatal("client outside whitelist should be blocked before web auth")
}
_, body = e.call(t, "POST", "/api/tunnels/"+created.ID+"/invites", map[string]any{})
var inv struct {
Invite protocol.Invite `json:"invite"`
}
_ = json.Unmarshal(body, &inv)
resp, err := http.PostForm(fmt.Sprintf("http://%s/auth", e.addr), url.Values{"code": {inv.Invite.Code}, "name": {"张三"}})
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
waitFor(t, "lease listed with expiry and name", func() bool {
leases := e.tunnel(t, created.ID).Metrics.DynamicLeases
return len(leases) == 1 && leases[0].IP == "127.0.0.1" && leases[0].Name == "张三" && time.Until(leases[0].ExpiresAt) > time.Hour
})
long, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
t.Fatal(err)
}
defer long.Close()
_ = long.SetDeadline(time.Now().Add(3 * time.Second))
_, _ = long.Write([]byte("hi"))
buf := make([]byte, 2)
if _, err := io.ReadFull(long, buf); err != nil {
t.Fatalf("leased client cannot use tunnel: %v", err)
}
// 在线连接接口应能看到这条连接
waitFor(t, "active connection listed", func() bool {
_, b := e.call(t, "GET", "/api/connections?tunnel_id="+created.ID, nil)
var out struct {
ActiveConns []protocol.ActiveConnInfo `json:"active_conns"`
}
_ = json.Unmarshal(b, &out)
return len(out.ActiveConns) == 1
})
status, b := e.call(t, "POST", "/api/tunnels/"+created.ID+"/leases/revoke", map[string]string{"ip": "127.0.0.1"})
if status != http.StatusOK {
t.Fatalf("revoke: %d %s", status, b)
}
waitFor(t, "lease removed", func() bool { return len(e.tunnel(t, created.ID).Metrics.DynamicLeases) == 0 })
// 撤销后已建立的连接被断开,新连接被拒绝
if _, err := long.Read(buf); err == nil {
t.Fatal("existing connection of revoked IP still alive")
}
if _, err := echoVia(port, "x"); err == nil {
t.Fatal("revoked IP can still connect")
}
}
func TestLinkOnlyInviteShareAndApproval(t *testing.T) {
e := startEnv(t, testToken)
waitFor(t, "agent connected", e.agent.IsConnected)
target := startEcho(t)
port := freeRelayPort(t)
status, body := e.call(t, "POST", "/api/tunnels", map[string]any{
"name": "db", "target": target, "listen_port": port,
})
if status != http.StatusCreated {
t.Fatalf("create: %d %s", status, body)
}
var created protocol.TunnelConfig
_ = json.Unmarshal(body, &created)
if len(created.Whitelist) != 0 {
t.Fatalf("empty whitelist must stay empty (link-only), got %v", created.Whitelist)
}
waitFor(t, "tunnel online", func() bool { return e.tunnel(t, created.ID).Metrics.Status == "online" })
if _, err := echoVia(port, "x"); err == nil {
t.Fatal("link-only tunnel must block clients without authorisation")
}
status, body = e.call(t, "POST", "/api/tunnels/"+created.ID+"/invites", map[string]any{"note": "张三", "lease_duration": "8h", "require_approval": true})
if status != http.StatusCreated {
t.Fatalf("create invite: %d %s", status, body)
}
var out struct {
Invite protocol.Invite `json:"invite"`
Link string `json:"link"`
ShareText string `json:"share_text"`
}
_ = json.Unmarshal(body, &out)
if out.Invite.MaxUses != 1 || time.Until(out.Invite.ExpiresAt) < 23*time.Hour || out.Invite.LeaseDuration != "8h" || !out.Invite.RequireApproval {
t.Fatalf("default invite should be 24h / 1 device: %+v", out.Invite)
}
for _, want := range []string{"给 张三", fmt.Sprintf("127.0.0.1:%d", port), "管理员批准", "8 小时", out.Link, "仅限 1 台设备"} {
if !strings.Contains(out.ShareText, want) {
t.Errorf("share text missing %q:\n%s", want, out.ShareText)
}
}
waitFor(t, "invite listed", func() bool { return len(e.tunnel(t, created.ID).Metrics.Invites) == 1 })
if status, _ := e.call(t, "GET", "/api/tunnels/"+created.ID+"/invites/"+out.Invite.Code+"/share", nil); status != http.StatusOK {
t.Fatalf("share text for existing invite: %d", status)
}
// 同事打开链接提交申请
resp, err := http.PostForm(fmt.Sprintf("http://%s/auth", e.addr), url.Values{"code": {out.Invite.Code}, "name": {"张三"}})
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
var pending protocol.AccessRequest
waitFor(t, "pending request listed", func() bool {
p := e.tunnel(t, created.ID).Metrics.Pending
if len(p) == 1 {
pending = p[0]
}
return len(p) == 1
})
if _, err := echoVia(port, "x"); err == nil {
t.Fatal("pending request must not grant access")
}
status, body = e.call(t, "POST", "/api/tunnels/"+created.ID+"/requests/"+pending.ID, map[string]bool{"approve": true})
if status != http.StatusOK {
t.Fatalf("approve: %d %s", status, body)
}
waitFor(t, "lease granted after approval", func() bool {
l := e.tunnel(t, created.ID).Metrics.DynamicLeases
return len(l) == 1 && l[0].Name == "张三" && time.Until(l[0].ExpiresAt) > 7*time.Hour
})
if got, err := echoVia(port, "hi"); err != nil || got != "hi" {
t.Fatalf("approved client cannot use tunnel: %q %v", got, err)
}
// 作废邀请后放通被收回
if status, _ := e.call(t, "DELETE", "/api/tunnels/"+created.ID+"/invites/"+out.Invite.Code, nil); status != http.StatusOK {
t.Fatal("revoke invite failed")
}
waitFor(t, "lease removed with invite", func() bool { return len(e.tunnel(t, created.ID).Metrics.DynamicLeases) == 0 })
}
func TestLinkOnlyWhitelistSerializesAsEmptyArray(t *testing.T) {
cm := newTestConfig(t, "127.0.0.1:1", testToken)
if _, err := cm.AddTunnel(protocol.TunnelConfig{Name: "a", Target: "10.0.0.1:22", ListenPort: 33703}); err != nil {
t.Fatal(err)
}
h := NewAPIServer(cm, NewAgent(cm), nil).Handler()
req := httptest.NewRequest("GET", "/api/tunnels", nil)
req.Host = "127.0.0.1:33080"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), `"whitelist":[]`) {
t.Fatalf("link-only whitelist must be [] (not null): %s", rec.Body.String())
}
}
func TestIdleAutoClose(t *testing.T) {
// 闲置单位缩短为 3 秒,便于测试(配置里的“1 分钟”即 3 秒)
e := startEnvWith(t, testToken, func(a *Agent) { a.idleUnit = 3 * time.Second })
waitFor(t, "agent connected", e.agent.IsConnected)
// 1) 没有任何连接/授权/邀请:闲置到期后自动关闭并记录原因
port := freeRelayPort(t)
_, body := e.call(t, "POST", "/api/tunnels", map[string]any{"name": "idle", "target": "127.0.0.1:1", "listen_port": port, "idle_close_minutes": 1})
var idle protocol.TunnelConfig
_ = json.Unmarshal(body, &idle)
waitFor(t, "idle tunnel closed", func() bool { return !e.tunnel(t, idle.ID).Enabled })
if got := e.tunnel(t, idle.ID).CloseReason; !strings.Contains(got, "闲置") {
t.Fatalf("close reason should mention idle, got %q", got)
}
// 2) 有可用的邀请链接时保持开启;作废后闲置关闭
port2 := freeRelayPort(t, port)
_, body = e.call(t, "POST", "/api/tunnels", map[string]any{"name": "invited", "target": "127.0.0.1:1", "listen_port": port2, "idle_close_minutes": 1})
var inv protocol.TunnelConfig
_ = json.Unmarshal(body, &inv)
waitFor(t, "tunnel online", func() bool { return e.tunnel(t, inv.ID).Metrics.Status == "online" })
_, ib := e.call(t, "POST", "/api/tunnels/"+inv.ID+"/invites", map[string]any{})
var ir struct {
Invite protocol.Invite `json:"invite"`
}
_ = json.Unmarshal(ib, &ir)
time.Sleep(6 * time.Second)
if !e.tunnel(t, inv.ID).Enabled {
t.Fatal("tunnel with an unused invite must stay open")
}
if act := e.tunnel(t, inv.ID).Activity; act == nil || !act.InUse {
t.Fatalf("tunnel with an unused invite should be reported in use: %+v", act)
}
e.call(t, "DELETE", "/api/tunnels/"+inv.ID+"/invites/"+ir.Invite.Code, nil)
waitFor(t, "tunnel closed after invite revoked", func() bool { return !e.tunnel(t, inv.ID).Enabled })
// 3) 不自动关闭(0)
port3 := freeRelayPort(t, port, port2)
_, body = e.call(t, "POST", "/api/tunnels", map[string]any{"name": "keep", "target": "127.0.0.1:1", "listen_port": port3, "idle_close_minutes": 0})
var keep protocol.TunnelConfig
_ = json.Unmarshal(body, &keep)
time.Sleep(6 * time.Second)
if !e.tunnel(t, keep.ID).Enabled {
t.Fatal("idle_close_minutes=0 must never auto-close")
}
}
func TestFirstConnectionAndConnectedTime(t *testing.T) {
e := startEnv(t, testToken)
waitFor(t, "agent connected", e.agent.IsConnected)
target := startEcho(t)
port := freeRelayPort(t)
_, body := e.call(t, "POST", "/api/tunnels", map[string]any{"name": "db", "target": target, "listen_port": port, "whitelist": []string{"*"}})
var tun protocol.TunnelConfig
_ = json.Unmarshal(body, &tun)
waitFor(t, "tunnel online", func() bool { return e.tunnel(t, tun.ID).Metrics.Status == "online" })
if e.tunnel(t, tun.ID).FirstConnectedAt != nil {
t.Fatal("no connection yet")
}
c, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
t.Fatal(err)
}
waitFor(t, "first connection recorded", func() bool { return e.tunnel(t, tun.ID).FirstConnectedAt != nil })
if rh := e.tunnel(t, tun.ID).RateHistory; rh == nil || len(rh.Up) != ratePoints || len(rh.Down) != ratePoints {
t.Fatalf("enabled tunnel should expose a %d-point rate history: %+v", ratePoints, rh)
}
time.Sleep(3 * time.Second)
act := e.tunnel(t, tun.ID).Activity
if act == nil || act.ConnectedSec < 2 || !act.InUse {
t.Fatalf("connected time should accumulate while a client is connected: %+v", act)
}
c.Close()
time.Sleep(2 * time.Second)
before := e.tunnel(t, tun.ID).Activity.ConnectedSec
time.Sleep(2 * time.Second)
if after := e.tunnel(t, tun.ID).Activity.ConnectedSec; after != before {
t.Fatalf("connected time must not grow without connections: %d -> %d", before, after)
}
e.call(t, "POST", "/api/tunnels/"+tun.ID+"/toggle", map[string]bool{"enabled": false})
got := e.tunnel(t, tun.ID)
if got.LastConnectedSec < 2 || got.CloseReason != "手动关闭" {
t.Fatalf("stop should keep last connected time and reason: sec=%d reason=%q", got.LastConnectedSec, got.CloseReason)
}
// 重新开启:本次统计清零
e.call(t, "POST", "/api/tunnels/"+tun.ID+"/toggle", map[string]bool{"enabled": true})
if got := e.tunnel(t, tun.ID); got.FirstConnectedAt != nil || got.ConnectedSec != 0 || got.CloseReason != "" {
t.Fatalf("new session should reset counters: %+v", got)
}
}
+702
View File
@@ -0,0 +1,702 @@
package client
import (
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log"
"math/big"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/DeepFal/PortRelay/core/acl"
"github.com/DeepFal/PortRelay/core/protocol"
)
const (
PortMin = 33000
PortMax = 34000
// ControlPort 是中继服务器上 PortRelay 自身的控制端口,不能分配给通道
ControlPort = 33000
// DefaultIdleCloseMinutes 未设置时的闲置自动关闭时长
DefaultIdleCloseMinutes = 30
maxClientHistory = 200
// 连接历史的落盘延迟:短连接密集时合并写入,避免每断开一个连接就重写一次配置
historySaveDelay = 2 * time.Second
)
func generateAuthCode() string {
const chars = "23456789abcdefghjkmnpqrstuvwxyz"
b := make([]byte, 8)
for i := range b {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
if err != nil {
panic(err)
}
b[i] = chars[n.Int64()]
}
return string(b)
}
// Config represents persistent client settings
type Config struct {
ServerAddr string `json:"server_addr"` // 中继服务器控制地址 (host:port)
Token string `json:"token"`
PublicHost string `json:"public_host,omitempty"` // 访问者连接通道时使用的网关地址
Tunnels []protocol.TunnelConfig `json:"tunnels"`
ClientHistory []protocol.ClientHistoryRecord `json:"client_history,omitempty"`
}
// ConfigManager handles loading and saving configuration
type ConfigManager struct {
path string
mu sync.RWMutex
cfg Config
historyTimer *time.Timer
closed bool
}
// DefaultConfig returns the first-run config: no server and no tunnels.
// 服务器地址、令牌与通道都属于用户数据,由用户在界面中填写,不能写死在程序里。
func DefaultConfig() Config {
return Config{
Tunnels: make([]protocol.TunnelConfig, 0),
ClientHistory: make([]protocol.ClientHistoryRecord, 0),
}
}
// NewConfigManager initializes or loads config from ~/.portrelay/config.json
func NewConfigManager() (*ConfigManager, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
return NewConfigManagerAt(filepath.Join(home, ".portrelay", "config.json"))
}
// NewConfigManagerAt initializes or loads config from the given path
func NewConfigManagerAt(filePath string) (*ConfigManager, error) {
if err := os.MkdirAll(filepath.Dir(filePath), 0700); err != nil {
return nil, err
}
cm := &ConfigManager{path: filePath}
err := cm.load()
switch {
case err == nil:
return cm, nil
case errors.Is(err, fs.ErrNotExist):
cm.cfg = DefaultConfig()
default:
// 配置损坏时先备份原文件,绝不直接覆盖用户数据
backup := fmt.Sprintf("%s.corrupt-%s", filePath, time.Now().Format("20060102-150405"))
if rerr := os.Rename(filePath, backup); rerr != nil {
return nil, fmt.Errorf("config %s is unreadable (%v) and could not be backed up: %w", filePath, err, rerr)
}
log.Printf("[Config] 配置文件损坏 (%v),已备份到 %s 并使用默认配置", err, backup)
cm.cfg = DefaultConfig()
}
if err := cm.save(); err != nil {
return nil, err
}
return cm, nil
}
func (cm *ConfigManager) load() error {
data, err := os.ReadFile(cm.path)
if err != nil {
return err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return err
}
// Ensure every tunnel has an auth code
for i := range cfg.Tunnels {
if cfg.Tunnels[i].AuthCode == "" {
cfg.Tunnels[i].AuthCode = generateAuthCode()
}
}
cm.cfg = cfg
return nil
}
// save writes the config atomically (temp file + rename). Caller must hold cm.mu.
func (cm *ConfigManager) save() error {
if cm.closed {
return nil
}
data, err := json.MarshalIndent(cm.cfg, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(cm.path), ".config-*.json")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpName, 0600); err != nil {
return err
}
return os.Rename(tmpName, cm.path)
}
// Close persists pending writes; afterwards the manager no longer touches the file (used on shutdown)
func (cm *ConfigManager) Close() error {
err := cm.Flush()
cm.mu.Lock()
cm.closed = true
cm.mu.Unlock()
return err
}
// Flush persists any pending (debounced) writes
func (cm *ConfigManager) Flush() error {
cm.mu.Lock()
defer cm.mu.Unlock()
if cm.historyTimer != nil {
cm.historyTimer.Stop()
cm.historyTimer = nil
}
return cm.save()
}
func cloneTunnel(t protocol.TunnelConfig) protocol.TunnelConfig {
// 空白名单(仅授权链接)必须序列化为 [],不能是 null:App 端按非可选数组解析
t.Whitelist = append(make([]string, 0, len(t.Whitelist)), t.Whitelist...)
t.DynamicWhitelist = append([]string(nil), t.DynamicWhitelist...)
if t.StartedAt != nil {
v := *t.StartedAt
t.StartedAt = &v
}
if t.ExpiresAt != nil {
v := *t.ExpiresAt
t.ExpiresAt = &v
}
return t
}
// Get returns a deep copy snapshot (safe to read without holding the lock)
func (cm *ConfigManager) Get() Config {
cm.mu.RLock()
defer cm.mu.RUnlock()
out := cm.cfg
out.Tunnels = make([]protocol.TunnelConfig, len(cm.cfg.Tunnels))
for i, t := range cm.cfg.Tunnels {
out.Tunnels[i] = cloneTunnel(t)
}
out.ClientHistory = append([]protocol.ClientHistoryRecord(nil), cm.cfg.ClientHistory...)
return out
}
// FindTunnel returns a copy of the tunnel with the given ID
func (cm *ConfigManager) FindTunnel(id string) (protocol.TunnelConfig, bool) {
cm.mu.RLock()
defer cm.mu.RUnlock()
for _, t := range cm.cfg.Tunnels {
if t.ID == id {
return cloneTunnel(t), true
}
}
return protocol.TunnelConfig{}, false
}
// ValidateServerAddr checks a host:port address
func ValidateServerAddr(addr string) error {
host, port, err := net.SplitHostPort(strings.TrimSpace(addr))
if err != nil || host == "" {
return fmt.Errorf("服务器地址格式应为 IP:端口")
}
if p, err := strconv.Atoi(port); err != nil || p <= 0 || p > 65535 {
return fmt.Errorf("服务器端口无效")
}
return nil
}
// PublicHostOrDefault returns the host LAN users connect to;
// 未单独配置时使用服务器控制地址中的主机部分
func (c Config) PublicHostOrDefault() string {
if h := strings.TrimSpace(c.PublicHost); h != "" {
return h
}
if h, _, err := net.SplitHostPort(c.ServerAddr); err == nil {
return h
}
return ""
}
// UpdateServer sets server address, token and the public access host.
// 返回 true 表示连接参数(地址/令牌)发生变化,需要重连。
func (cm *ConfigManager) UpdateServer(addr, token, publicHost string) (bool, error) {
addr = strings.TrimSpace(addr)
token = strings.TrimSpace(token)
publicHost = strings.TrimSpace(publicHost)
if err := ValidateServerAddr(addr); err != nil {
return false, err
}
if publicHost != "" && (strings.ContainsAny(publicHost, " /:") || strings.Contains(publicHost, "://")) {
return false, fmt.Errorf("对外访问地址只填主机名或 IP,不要带端口或协议")
}
cm.mu.Lock()
defer cm.mu.Unlock()
changed := cm.cfg.ServerAddr != addr || cm.cfg.Token != token
cm.cfg.ServerAddr = addr
cm.cfg.Token = token
cm.cfg.PublicHost = publicHost
return changed, cm.save()
}
// normalizeAndValidate trims user input and checks the user-editable fields
func normalizeAndValidate(t *protocol.TunnelConfig) error {
t.Name = strings.TrimSpace(t.Name)
t.Target = strings.TrimSpace(t.Target)
t.Owner = strings.TrimSpace(t.Owner)
if t.Name == "" || t.Target == "" {
return fmt.Errorf("名称与目标地址为必填项")
}
if host, port, err := net.SplitHostPort(t.Target); err != nil || host == "" {
return fmt.Errorf("目标地址格式应为 主机:端口,例如 10.0.0.10:3306")
} else if p, err := strconv.Atoi(port); err != nil || p <= 0 || p > 65535 {
return fmt.Errorf("目标端口无效")
}
if t.ListenPort < PortMin || t.ListenPort > PortMax {
return fmt.Errorf("监听端口须在 %d-%d 范围内", PortMin, PortMax)
}
if t.ListenPort == ControlPort {
return fmt.Errorf("端口 %d 是服务器控制端口,不能用于通道", ControlPort)
}
if t.DurationMinutes < 0 {
return fmt.Errorf("开启时长不能为负数")
}
if t.MaxBandwidthMB < 0 {
return fmt.Errorf("限速不能为负数")
}
if t.IdleCloseMinutes != nil && (*t.IdleCloseMinutes < 0 || *t.IdleCloseMinutes > 7*24*60) {
return fmt.Errorf("闲置自动关闭时长须在 0(不自动关闭)到 7 天之间")
}
// 白名单为空表示“仅授权链接”:只有通过邀请链接放通的设备可以访问。
// 放通全部必须显式填写 "*",不能因为留空而意外对所有人开放。
wl := make([]string, 0, len(t.Whitelist))
for _, r := range t.Whitelist {
r = strings.TrimSpace(r)
if r == "" {
continue
}
if err := acl.ValidateRule(r); err != nil {
return err
}
wl = append(wl, r)
}
t.Whitelist = wl
// 放通时长与是否审批已改为在每个邀请上设置,通道级字段不再使用
t.LeaseDuration = ""
t.RequireApproval = false
return nil
}
// checkPortLocked returns an error if another tunnel already uses the port. Caller must hold cm.mu.
func (cm *ConfigManager) checkPortLocked(id string, port int) error {
for _, existing := range cm.cfg.Tunnels {
if existing.ID != id && existing.ListenPort == port {
return fmt.Errorf("端口 %d 已被「%s」使用", port, existing.Name)
}
}
return nil
}
// AddTunnel adds a new (enabled) tunnel
func (cm *ConfigManager) AddTunnel(t protocol.TunnelConfig) (protocol.TunnelConfig, error) {
if err := normalizeAndValidate(&t); err != nil {
return t, err
}
cm.mu.Lock()
defer cm.mu.Unlock()
t.ID = uuid.New().String()
t.AuthCode = generateAuthCode()
t.HistoricalBytesIn, t.HistoricalBytesOut, t.LastDurationSec = 0, 0, 0
t.CloseReason, t.FirstConnectedAt, t.ConnectedSec, t.LastConnectedSec = "", nil, 0, 0
if t.IdleCloseMinutes == nil {
v := DefaultIdleCloseMinutes
t.IdleCloseMinutes = &v
}
t.DynamicWhitelist = nil
if err := cm.checkPortLocked(t.ID, t.ListenPort); err != nil {
return t, err
}
t.Enabled = true
now := time.Now()
t.StartedAt = &now
t.ExpiresAt = nil
if t.DurationMinutes > 0 {
exp := now.Add(time.Duration(t.DurationMinutes) * time.Minute)
t.ExpiresAt = &exp
}
cm.cfg.Tunnels = append(cm.cfg.Tunnels, t)
return cloneTunnel(t), cm.save()
}
// PatchTunnel applies a partial JSON update: only fields present in patch change.
// 启用状态、计时、流量统计、授权码由服务端逻辑维护,不能通过编辑修改。
// Returns the updated tunnel and its previous listen port.
func (cm *ConfigManager) PatchTunnel(id string, patch []byte) (protocol.TunnelConfig, int, error) {
cm.mu.Lock()
defer cm.mu.Unlock()
idx := -1
for i, t := range cm.cfg.Tunnels {
if t.ID == id {
idx = i
break
}
}
if idx < 0 {
return protocol.TunnelConfig{}, 0, fmt.Errorf("通道 %s 未找到", id)
}
existing := cm.cfg.Tunnels[idx]
updated := cloneTunnel(existing)
if err := json.Unmarshal(patch, &updated); err != nil {
return existing, existing.ListenPort, fmt.Errorf("Invalid JSON")
}
// 受保护字段
updated.ID = existing.ID
updated.Enabled = existing.Enabled
updated.StartedAt = existing.StartedAt
updated.ExpiresAt = existing.ExpiresAt
updated.HistoricalBytesIn = existing.HistoricalBytesIn
updated.HistoricalBytesOut = existing.HistoricalBytesOut
updated.LastDurationSec = existing.LastDurationSec
updated.CloseReason = existing.CloseReason
updated.FirstConnectedAt = existing.FirstConnectedAt
updated.ConnectedSec = existing.ConnectedSec
updated.LastConnectedSec = existing.LastConnectedSec
updated.AuthCode = existing.AuthCode
updated.DynamicWhitelist = nil
if err := normalizeAndValidate(&updated); err != nil {
return existing, existing.ListenPort, err
}
if err := cm.checkPortLocked(id, updated.ListenPort); err != nil {
return existing, existing.ListenPort, err
}
// 运行中修改时长:从本次开启时间重新计算到期时间
if updated.Enabled && updated.DurationMinutes != existing.DurationMinutes {
updated.ExpiresAt = nil
if updated.DurationMinutes > 0 {
start := time.Now()
if updated.StartedAt != nil {
start = *updated.StartedAt
}
exp := start.Add(time.Duration(updated.DurationMinutes) * time.Minute)
updated.ExpiresAt = &exp
}
}
cm.cfg.Tunnels[idx] = updated
return cloneTunnel(updated), existing.ListenPort, cm.save()
}
// RefreshAuthCode generates a new auth code for a tunnel
func (cm *ConfigManager) RefreshAuthCode(id string) (protocol.TunnelConfig, error) {
cm.mu.Lock()
defer cm.mu.Unlock()
for i, t := range cm.cfg.Tunnels {
if t.ID == id {
t.AuthCode = generateAuthCode()
cm.cfg.Tunnels[i] = t
return cloneTunnel(t), cm.save()
}
}
return protocol.TunnelConfig{}, fmt.Errorf("通道 %s 未找到", id)
}
// CurrentEnabled reports whether the tunnel is enabled
func (cm *ConfigManager) CurrentEnabled(id string) (bool, error) {
t, ok := cm.FindTunnel(id)
if !ok {
return false, fmt.Errorf("未找到通道 %s", id)
}
return t.Enabled, nil
}
// IdleCloseMinutes returns the effective idle auto-close setting (0 = never)
func IdleCloseMinutes(t protocol.TunnelConfig) int {
if t.IdleCloseMinutes == nil {
return DefaultIdleCloseMinutes
}
return *t.IdleCloseMinutes
}
// stopLocked marks a tunnel stopped: records run time, real connected time, traffic and the reason
func stopLocked(t *protocol.TunnelConfig, now time.Time, sessionIn, sessionOut int64, reason string) {
t.Enabled = false
if t.StartedAt != nil {
t.LastDurationSec = int64(now.Sub(*t.StartedAt).Seconds())
}
t.LastConnectedSec = t.ConnectedSec
t.HistoricalBytesIn += sessionIn
t.HistoricalBytesOut += sessionOut
t.StartedAt = nil
t.ExpiresAt = nil
t.CloseReason = reason
}
// CloseTunnel stops an enabled tunnel with the given reason (e.g. idle auto-close)
func (cm *ConfigManager) CloseTunnel(id, reason string, sessionBytes func(id string) (int64, int64)) (protocol.TunnelConfig, bool) {
cm.mu.Lock()
defer cm.mu.Unlock()
for i, t := range cm.cfg.Tunnels {
if t.ID != id || !t.Enabled {
continue
}
in, out := sessionBytes(t.ID)
stopLocked(&t, time.Now(), in, out, reason)
cm.cfg.Tunnels[i] = t
if err := cm.save(); err != nil {
log.Printf("[Config] save failed: %v", err)
}
return cloneTunnel(t), true
}
return protocol.TunnelConfig{}, false
}
// MarkFirstConnected records the first connection of the current session (once)
func (cm *ConfigManager) MarkFirstConnected(id string, at time.Time) {
cm.mu.Lock()
defer cm.mu.Unlock()
for i, t := range cm.cfg.Tunnels {
if t.ID == id && t.Enabled && t.FirstConnectedAt == nil {
cm.cfg.Tunnels[i].FirstConnectedAt = &at
if err := cm.save(); err != nil {
log.Printf("[Config] save failed: %v", err)
}
return
}
}
}
// AddConnectedSeconds accumulates the real connected time of the current session
func (cm *ConfigManager) AddConnectedSeconds(id string, secs int64) {
if secs <= 0 {
return
}
cm.mu.Lock()
defer cm.mu.Unlock()
for i, t := range cm.cfg.Tunnels {
if t.ID == id && t.Enabled {
cm.cfg.Tunnels[i].ConnectedSec += secs
if err := cm.save(); err != nil {
log.Printf("[Config] save failed: %v", err)
}
return
}
}
}
// SetTunnelEnabled turns a tunnel on/off, handling TTL and merging session traffic on stop.
// changed=false means it was already in the requested state (idempotent).
func (cm *ConfigManager) SetTunnelEnabled(id string, enabled bool, sessionIn, sessionOut int64) (protocol.TunnelConfig, bool, error) {
cm.mu.Lock()
defer cm.mu.Unlock()
for i, t := range cm.cfg.Tunnels {
if t.ID != id {
continue
}
if t.Enabled == enabled {
return cloneTunnel(t), false, nil
}
now := time.Now()
if enabled {
t.Enabled = true
t.StartedAt = &now
t.ExpiresAt = nil
if t.DurationMinutes > 0 {
exp := now.Add(time.Duration(t.DurationMinutes) * time.Minute)
t.ExpiresAt = &exp
}
t.CloseReason, t.FirstConnectedAt, t.ConnectedSec = "", nil, 0
} else {
stopLocked(&t, now, sessionIn, sessionOut, "手动关闭")
}
cm.cfg.Tunnels[i] = t
return cloneTunnel(t), true, cm.save()
}
return protocol.TunnelConfig{}, false, fmt.Errorf("未找到通道 %s", id)
}
// ExpireDue disables every enabled tunnel whose TTL has passed and returns them.
// sessionBytes returns (and consumes) the current session traffic for a tunnel.
func (cm *ConfigManager) ExpireDue(sessionBytes func(id string) (int64, int64)) []protocol.TunnelConfig {
cm.mu.Lock()
defer cm.mu.Unlock()
now := time.Now()
var expired []protocol.TunnelConfig
for i, t := range cm.cfg.Tunnels {
if !t.Enabled || t.ExpiresAt == nil || !now.After(*t.ExpiresAt) {
continue
}
in, out := sessionBytes(t.ID)
stopLocked(&t, now, in, out, fmt.Sprintf("达到最长开启时长(%s)", formatMinutes(t.DurationMinutes)))
cm.cfg.Tunnels[i] = t
expired = append(expired, cloneTunnel(t))
}
if len(expired) > 0 {
if err := cm.save(); err != nil {
log.Printf("[Config] save failed: %v", err)
}
}
return expired
}
func formatMinutes(m int) string {
switch {
case m >= 60*24 && m%(60*24) == 0:
return fmt.Sprintf("%d 天", m/(60*24))
case m >= 60 && m%60 == 0:
return fmt.Sprintf("%d 小时", m/60)
default:
return fmt.Sprintf("%d 分钟", m)
}
}
// AddHistoricalTraffic merges session traffic that is about to be lost (disconnect / port move)
func (cm *ConfigManager) AddHistoricalTraffic(traffic map[string][2]int64) {
if len(traffic) == 0 {
return
}
cm.mu.Lock()
defer cm.mu.Unlock()
changed := false
for i, t := range cm.cfg.Tunnels {
if v, ok := traffic[t.ID]; ok && (v[0] != 0 || v[1] != 0) {
cm.cfg.Tunnels[i].HistoricalBytesIn += v[0]
cm.cfg.Tunnels[i].HistoricalBytesOut += v[1]
changed = true
}
}
if changed {
if err := cm.save(); err != nil {
log.Printf("[Config] save failed: %v", err)
}
}
}
// DeleteTunnel removes a tunnel
func (cm *ConfigManager) DeleteTunnel(id string) error {
cm.mu.Lock()
defer cm.mu.Unlock()
newTunnels := make([]protocol.TunnelConfig, 0, len(cm.cfg.Tunnels))
found := false
for _, t := range cm.cfg.Tunnels {
if t.ID == id {
found = true
continue
}
newTunnels = append(newTunnels, t)
}
if !found {
return fmt.Errorf("通道 %s 未找到", id)
}
cm.cfg.Tunnels = newTunnels
return cm.save()
}
// AddClientHistory appends a client connection log record (persisted with a short delay)
func (cm *ConfigManager) AddClientHistory(rec protocol.ClientHistoryRecord) {
cm.mu.Lock()
defer cm.mu.Unlock()
hist := make([]protocol.ClientHistoryRecord, 0, min(len(cm.cfg.ClientHistory)+1, maxClientHistory))
hist = append(hist, rec)
hist = append(hist, cm.cfg.ClientHistory...)
if len(hist) > maxClientHistory {
hist = hist[:maxClientHistory]
}
cm.cfg.ClientHistory = hist
if cm.historyTimer == nil && !cm.closed {
cm.historyTimer = time.AfterFunc(historySaveDelay, func() {
cm.mu.Lock()
defer cm.mu.Unlock()
cm.historyTimer = nil
if err := cm.save(); err != nil {
log.Printf("[Config] save failed: %v", err)
}
})
}
}
// GetClientHistory returns client connection records (optionally filtered by tunnelId)
func (cm *ConfigManager) GetClientHistory(tunnelID string) []protocol.ClientHistoryRecord {
cm.mu.RLock()
defer cm.mu.RUnlock()
filtered := make([]protocol.ClientHistoryRecord, 0)
for _, r := range cm.cfg.ClientHistory {
if tunnelID == "" || r.TunnelID == tunnelID {
filtered = append(filtered, r)
}
}
return filtered
}
// RecommendPort finds an unused port in 33000-34000
func (cm *ConfigManager) RecommendPort() int {
cm.mu.RLock()
defer cm.mu.RUnlock()
used := make(map[int]bool)
for _, t := range cm.cfg.Tunnels {
used[t.ListenPort] = true
}
for p := 33660; p <= PortMax; p++ {
if !used[p] {
return p
}
}
for p := PortMin; p < 33660; p++ {
if p == ControlPort {
continue
}
if !used[p] {
return p
}
}
return 0
}
+45
View File
@@ -0,0 +1,45 @@
package client
import (
"fmt"
"net"
"time"
)
// ProbeResult holds ping/reachability info
type ProbeResult struct {
Target string `json:"target"`
Reachable bool `json:"reachable"`
RTT time.Duration `json:"rtt"`
Error string `json:"error,omitempty"`
}
// ProbeTarget checks if this machine can reach target
func ProbeTarget(target string, timeout time.Duration) ProbeResult {
if timeout <= 0 {
timeout = 2 * time.Second
}
start := time.Now()
conn, err := net.DialTimeout("tcp", target, timeout)
if err != nil {
return ProbeResult{
Target: target,
Reachable: false,
RTT: time.Since(start),
Error: err.Error(),
}
}
defer conn.Close()
return ProbeResult{
Target: target,
Reachable: true,
RTT: time.Since(start),
}
}
// FormatRTT formats duration in ms
func (pr ProbeResult) FormatRTT() string {
return fmt.Sprintf("%.1f ms", float64(pr.RTT.Microseconds())/1000.0)
}
+127
View File
@@ -0,0 +1,127 @@
package client
import (
"sync"
"time"
)
const (
rateStep = 5 * time.Second
ratePoints = 120 // 10 分钟
totalSeries = "__total__"
)
// RateSeries is a fixed-length rate history (oldest first). The last point is the
// current, still-accumulating bucket so the chart moves every second.
type RateSeries struct {
End int64 `json:"end"` // 最后一个点所在 5 秒桶的起始时间(Unix 秒)
Step int `json:"step"` // 每个点的秒数
Up []int64 `json:"up"` // 上行 bytes/s(客户端 → 目标)
Down []int64 `json:"down"` // 下行 bytes/s(目标 → 客户端)
}
type rateBucket struct {
start time.Time
up, down int64
}
type rateHistory struct {
done []rateBucket // completed buckets, oldest first, at most ratePoints
curStart time.Time
curUp, curDown int64
curSamples int64
lastSampleBucket time.Time
}
func (h *rateHistory) add(now time.Time, up, down int64) {
bs := now.Truncate(rateStep)
if h.curStart.IsZero() {
h.curStart = bs
}
if bs.After(h.curStart) {
if h.curSamples > 0 {
h.done = append(h.done, rateBucket{h.curStart, h.curUp / h.curSamples, h.curDown / h.curSamples})
}
if len(h.done) > ratePoints {
h.done = h.done[len(h.done)-ratePoints:]
}
h.curStart, h.curUp, h.curDown, h.curSamples = bs, 0, 0, 0
}
h.curUp += up
h.curDown += down
h.curSamples++
}
// series renders the last ratePoints buckets ending at now's bucket; missing buckets are zero
func (h *rateHistory) series(now time.Time) RateSeries {
end := now.Truncate(rateStep)
s := RateSeries{End: end.Unix(), Step: int(rateStep / time.Second), Up: make([]int64, ratePoints), Down: make([]int64, ratePoints)}
first := end.Add(-time.Duration(ratePoints-1) * rateStep)
put := func(start time.Time, up, down int64) {
if start.Before(first) || start.After(end) {
return
}
i := int(start.Sub(first) / rateStep)
s.Up[i], s.Down[i] = up, down
}
for _, b := range h.done {
put(b.start, b.up, b.down)
}
if h.curSamples > 0 {
put(h.curStart, h.curUp/h.curSamples, h.curDown/h.curSamples)
}
return s
}
// rateRecorder keeps per-tunnel and total rate histories in memory (近 10 分钟,不落盘)
type rateRecorder struct {
mu sync.Mutex
hist map[string]*rateHistory
}
func newRateRecorder() *rateRecorder {
return &rateRecorder{hist: make(map[string]*rateHistory)}
}
// record adds one sample per tunnel (rates in bytes/s) plus the total
func (r *rateRecorder) record(now time.Time, rates map[string][2]int64) {
r.mu.Lock()
defer r.mu.Unlock()
var tu, td int64
for id, v := range rates {
h := r.hist[id]
if h == nil {
h = &rateHistory{}
r.hist[id] = h
}
h.add(now, v[0], v[1])
tu += v[0]
td += v[1]
}
t := r.hist[totalSeries]
if t == nil {
t = &rateHistory{}
r.hist[totalSeries] = t
}
t.add(now, tu, td)
}
func (r *rateRecorder) series(id string, now time.Time) RateSeries {
r.mu.Lock()
defer r.mu.Unlock()
if h := r.hist[id]; h != nil {
return h.series(now)
}
return (&rateHistory{}).series(now)
}
// forget drops the history of tunnels that are no longer enabled
func (r *rateRecorder) retain(enabled map[string]bool) {
r.mu.Lock()
defer r.mu.Unlock()
for id := range r.hist {
if id != totalSeries && !enabled[id] {
delete(r.hist, id)
}
}
}
+52
View File
@@ -0,0 +1,52 @@
package client
import (
"testing"
"time"
)
func TestRateHistoryBucketsAndWindow(t *testing.T) {
r := newRateRecorder()
base := time.Unix(1_700_000_000, 0).Truncate(rateStep)
// 同一个 5 秒桶内的多个采样取平均
r.record(base, map[string][2]int64{"a": {100, 1000}})
r.record(base.Add(1*time.Second), map[string][2]int64{"a": {300, 3000}})
// 跳过 3 个桶(中间无数据,应为 0)
r.record(base.Add(20*time.Second), map[string][2]int64{"a": {50, 500}})
s := r.series("a", base.Add(21*time.Second))
if len(s.Up) != ratePoints || len(s.Down) != ratePoints || s.Step != 5 {
t.Fatalf("series must be fixed length %d with step 5: %d/%d step %d", ratePoints, len(s.Up), len(s.Down), s.Step)
}
last := ratePoints - 1
if s.Up[last] != 50 || s.Down[last] != 500 {
t.Fatalf("last point should be the current bucket: %d/%d", s.Up[last], s.Down[last])
}
if s.Up[last-4] != 200 || s.Down[last-4] != 2000 {
t.Fatalf("first bucket should average samples: %d/%d", s.Up[last-4], s.Down[last-4])
}
for i := last - 3; i < last; i++ {
if s.Up[i] != 0 || s.Down[i] != 0 {
t.Fatalf("gap bucket %d should be zero: %d/%d", i, s.Up[i], s.Down[i])
}
}
// 合计曲线
total := r.series(totalSeries, base.Add(21*time.Second))
if total.Down[last] != 500 {
t.Fatalf("total should sum tunnels: %d", total.Down[last])
}
// 11 分钟后旧数据滑出窗口
later := base.Add(11 * time.Minute)
if s2 := r.series("a", later); s2.Up[0] != 0 || s2.Down[last] != 0 {
t.Fatalf("old data should slide out of the 10 minute window: first=%d last=%d", s2.Up[0], s2.Down[last])
}
// 关闭的通道清除曲线
r.retain(map[string]bool{})
if s3 := r.series("a", base.Add(21*time.Second)); s3.Down[last] != 0 {
t.Fatal("history of a disabled tunnel should be dropped")
}
}