PortRelay:把内网服务临时、可控地开放给同事
边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上; 通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。 - portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页) - portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计) - mac-native:SwiftUI 菜单栏 App
This commit is contained in:
commit
86e159b3f0
67 files changed
+13888
No files matched your search
+807
@@ -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
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user