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
@@ -0,0 +1,611 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/yamux"
|
||||
|
||||
"github.com/DeepFal/PortRelay/core/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultPortMin = 33000
|
||||
DefaultPortMax = 34000
|
||||
|
||||
// authTimeout 是新连接完成鉴权的最长等待时间,超时直接断开
|
||||
authTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// agentSession 表示一个已建立的 Agent 连接,控制流写入与指标推送都绑定在该会话上
|
||||
type agentSession struct {
|
||||
sess *yamux.Session
|
||||
writer *bufio.Writer
|
||||
writeMu sync.Mutex
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (s *agentSession) send(v any) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
data = append(data, '\n')
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
_, _ = s.writer.Write(data)
|
||||
_ = s.writer.Flush()
|
||||
}
|
||||
|
||||
// Manager manages all active tunnels on the server node
|
||||
type Manager struct {
|
||||
portMin int
|
||||
portMax int
|
||||
token string
|
||||
|
||||
// PublicHost 显示在授权页面上的中转访问地址;为空时使用访问者打开链接时的主机名
|
||||
PublicHost string
|
||||
// TrustProxyHeaders 为 true 时 /auth 信任 X-Forwarded-For / X-Real-IP(仅在前置可信反向代理时开启)
|
||||
TrustProxyHeaders bool
|
||||
|
||||
mu sync.RWMutex
|
||||
active *agentSession
|
||||
tunnels map[string]*Tunnel
|
||||
portMap map[int]string // port -> tunnelID
|
||||
|
||||
secEvents []protocol.SecurityEvent
|
||||
secEventsMu sync.Mutex
|
||||
|
||||
state *stateStore // 临时放通、邀请链接、审批申请
|
||||
}
|
||||
|
||||
// NewManager creates a manager
|
||||
func NewManager(portMin, portMax int, token string) *Manager {
|
||||
if portMin <= 0 {
|
||||
portMin = DefaultPortMin
|
||||
}
|
||||
if portMax <= 0 {
|
||||
portMax = DefaultPortMax
|
||||
}
|
||||
return &Manager{
|
||||
portMin: portMin,
|
||||
portMax: portMax,
|
||||
token: token,
|
||||
tunnels: make(map[string]*Tunnel),
|
||||
portMap: make(map[int]string),
|
||||
secEvents: make([]protocol.SecurityEvent, 0),
|
||||
state: newStateStore(),
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSession handles an incoming connection from the Mac agent.
|
||||
// 只有在控制流上通过 token 鉴权后,该连接才会成为当前活跃会话。
|
||||
func (m *Manager) HandleSession(rawConn net.Conn) error {
|
||||
defer rawConn.Close()
|
||||
|
||||
cfg := yamux.DefaultConfig()
|
||||
cfg.EnableKeepAlive = true
|
||||
cfg.KeepAliveInterval = 10 * time.Second
|
||||
cfg.LogOutput = io.Discard
|
||||
|
||||
session, err := yamux.Server(rawConn, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("yamux server init error: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// 非 Agent 连接(端口扫描、探活等)在超时内打不开控制流,直接丢弃
|
||||
type acceptResult struct {
|
||||
stream net.Conn
|
||||
err error
|
||||
}
|
||||
acceptCh := make(chan acceptResult, 1)
|
||||
go func() {
|
||||
st, err := session.AcceptStream()
|
||||
acceptCh <- acceptResult{st, err}
|
||||
}()
|
||||
var controlStream net.Conn
|
||||
select {
|
||||
case res := <-acceptCh:
|
||||
if res.err != nil {
|
||||
return fmt.Errorf("failed to accept control stream: %w", res.err)
|
||||
}
|
||||
controlStream = res.stream
|
||||
case <-time.After(authTimeout):
|
||||
return errors.New("timed out waiting for control stream")
|
||||
}
|
||||
defer controlStream.Close()
|
||||
|
||||
as := &agentSession{
|
||||
sess: session,
|
||||
writer: bufio.NewWriter(controlStream),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
defer close(as.done)
|
||||
|
||||
reader := bufio.NewReader(controlStream)
|
||||
|
||||
// 1. 第一条消息必须是鉴权
|
||||
_ = controlStream.SetReadDeadline(time.Now().Add(authTimeout))
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("read auth message: %w", err)
|
||||
}
|
||||
_ = controlStream.SetReadDeadline(time.Time{})
|
||||
|
||||
var auth protocol.AuthMessage
|
||||
if err := json.Unmarshal(line, &auth); err != nil || auth.Type != protocol.TypeAuth {
|
||||
as.send(protocol.AuthRespMessage{Type: protocol.TypeAuthResp, Success: false, Error: "first message must be auth"})
|
||||
return errors.New("first control message is not auth")
|
||||
}
|
||||
if !m.checkToken(auth.Token) {
|
||||
as.send(protocol.AuthRespMessage{Type: protocol.TypeAuthResp, Success: false, Error: "invalid authentication token"})
|
||||
return errors.New("invalid authentication token")
|
||||
}
|
||||
|
||||
// 2. 鉴权通过:替换旧会话,旧会话的通道全部释放,由新 Agent 重新注册
|
||||
m.activate(as)
|
||||
defer m.deactivate(as)
|
||||
as.send(protocol.AuthRespMessage{Type: protocol.TypeAuthResp, Success: true})
|
||||
log.Printf("[Server] Agent %s authenticated, control stream established", rawConn.RemoteAddr())
|
||||
|
||||
go m.metricsLoop(as)
|
||||
|
||||
// 3. 控制消息循环
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Printf("[Server] control stream read error: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var base protocol.BaseMessage
|
||||
if err := json.Unmarshal(line, &base); err != nil {
|
||||
log.Printf("[Server] malformed control message: %v", err)
|
||||
continue
|
||||
}
|
||||
m.handleControlMessage(as, base.Type, line)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) checkToken(token string) bool {
|
||||
if m.token == "" {
|
||||
return true
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(token), []byte(m.token)) == 1
|
||||
}
|
||||
|
||||
func (m *Manager) activate(as *agentSession) {
|
||||
m.mu.Lock()
|
||||
old := m.active
|
||||
m.active = as
|
||||
m.closeAllTunnelsLocked()
|
||||
m.mu.Unlock()
|
||||
|
||||
if old != nil {
|
||||
log.Printf("[Server] New agent session replaces the previous one")
|
||||
_ = old.sess.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// deactivate 只在该会话仍是活跃会话时清理,避免旧会话迟到的断开把新会话的通道清掉
|
||||
func (m *Manager) deactivate(as *agentSession) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.active != as {
|
||||
return
|
||||
}
|
||||
m.active = nil
|
||||
m.closeAllTunnelsLocked()
|
||||
log.Printf("[Server] Agent disconnected, cleaned up all tunnels")
|
||||
}
|
||||
|
||||
func (m *Manager) closeAllTunnelsLocked() {
|
||||
for _, t := range m.tunnels {
|
||||
t.Close()
|
||||
}
|
||||
m.tunnels = make(map[string]*Tunnel)
|
||||
m.portMap = make(map[int]string)
|
||||
}
|
||||
|
||||
func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []byte) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[Server] PANIC while handling control message %q: %v", msgType, r)
|
||||
}
|
||||
}()
|
||||
switch msgType {
|
||||
case protocol.TypeRegisterTunnel:
|
||||
var msg protocol.RegisterTunnelMessage
|
||||
if json.Unmarshal(raw, &msg) == nil {
|
||||
as.send(m.registerTunnel(as, msg.Tunnel))
|
||||
}
|
||||
|
||||
case protocol.TypeUnregister:
|
||||
var msg protocol.UnregisterMessage
|
||||
if json.Unmarshal(raw, &msg) == nil {
|
||||
m.unregisterTunnel(as, msg.TunnelID)
|
||||
as.send(protocol.UnregisterRespMessage{Type: protocol.TypeUnregisterResp, TunnelID: msg.TunnelID, Success: true})
|
||||
}
|
||||
|
||||
case protocol.TypeUpdateTunnel:
|
||||
var msg protocol.UpdateTunnelMessage
|
||||
if json.Unmarshal(raw, &msg) == nil {
|
||||
as.send(m.updateTunnel(as, msg.Tunnel))
|
||||
}
|
||||
|
||||
case protocol.TypeRevokeLease:
|
||||
var msg protocol.RevokeLeaseMessage
|
||||
if json.Unmarshal(raw, &msg) == nil {
|
||||
m.revokeLease(msg.TunnelID, msg.IP)
|
||||
}
|
||||
|
||||
case protocol.TypeCreateInvite:
|
||||
var msg protocol.CreateInviteMessage
|
||||
if json.Unmarshal(raw, &msg) == nil {
|
||||
as.send(m.createInvite(msg))
|
||||
}
|
||||
|
||||
case protocol.TypeRevokeInvite:
|
||||
var msg protocol.RevokeInviteMessage
|
||||
if json.Unmarshal(raw, &msg) == nil {
|
||||
m.revokeInvite(msg.TunnelID, msg.Code)
|
||||
}
|
||||
|
||||
case protocol.TypeDecideRequest:
|
||||
var msg protocol.DecideRequestMessage
|
||||
if json.Unmarshal(raw, &msg) == nil {
|
||||
m.decideRequest(msg.TunnelID, msg.RequestID, msg.Approve)
|
||||
}
|
||||
|
||||
case protocol.TypeKillConn:
|
||||
var msg protocol.KillConnMessage
|
||||
if json.Unmarshal(raw, &msg) == nil {
|
||||
m.killConn(msg.ConnID)
|
||||
}
|
||||
|
||||
case protocol.TypePing:
|
||||
as.send(protocol.BaseMessage{Type: protocol.TypePong})
|
||||
}
|
||||
}
|
||||
|
||||
func registerResp(cfg protocol.TunnelConfig, err error) protocol.RegisterRespMessage {
|
||||
resp := protocol.RegisterRespMessage{
|
||||
Type: protocol.TypeRegisterResp,
|
||||
TunnelID: cfg.ID,
|
||||
Success: err == nil,
|
||||
Port: cfg.ListenPort,
|
||||
}
|
||||
if err != nil {
|
||||
resp.Error = err.Error()
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// checkPortLocked 校验端口范围以及是否被其它通道占用
|
||||
func (m *Manager) checkPortLocked(cfg protocol.TunnelConfig) error {
|
||||
if cfg.ListenPort < m.portMin || cfg.ListenPort > m.portMax {
|
||||
return fmt.Errorf("port %d out of allowed range [%d, %d]", cfg.ListenPort, m.portMin, m.portMax)
|
||||
}
|
||||
if existingID, inUse := m.portMap[cfg.ListenPort]; inUse && existingID != cfg.ID {
|
||||
return fmt.Errorf("port %d already in use by tunnel %s", cfg.ListenPort, existingID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) registerTunnel(as *agentSession, cfg protocol.TunnelConfig) protocol.RegisterRespMessage {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.active != as {
|
||||
return registerResp(cfg, errors.New("agent session is no longer active"))
|
||||
}
|
||||
if err := m.checkPortLocked(cfg); err != nil {
|
||||
return registerResp(cfg, err)
|
||||
}
|
||||
|
||||
// 同 ID 重新注册:先关闭旧监听(端口可能相同,必须先释放),统计与临时白名单沿用
|
||||
old := m.tunnels[cfg.ID]
|
||||
if old != nil {
|
||||
old.Close()
|
||||
delete(m.portMap, old.ListenPort())
|
||||
delete(m.tunnels, cfg.ID)
|
||||
}
|
||||
|
||||
t, err := newTunnel(cfg, as.sess, m.addSecEvent, m.onClientConnect, old)
|
||||
if err != nil {
|
||||
return registerResp(cfg, err)
|
||||
}
|
||||
if old == nil {
|
||||
// 服务端重启或 Agent 重连后,恢复该通道尚未过期的临时放通
|
||||
t.restoreDynamicLeases(m.state.leaseExpiries(cfg.ID))
|
||||
}
|
||||
m.tunnels[cfg.ID] = t
|
||||
m.portMap[cfg.ListenPort] = cfg.ID
|
||||
|
||||
log.Printf("[Server] Registered tunnel '%s' listening on 0.0.0.0:%d", cfg.Name, cfg.ListenPort)
|
||||
return registerResp(cfg, nil)
|
||||
}
|
||||
|
||||
func (m *Manager) unregisterTunnel(as *agentSession, tunnelID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.active != as {
|
||||
return
|
||||
}
|
||||
// 主动关闭/删除通道时,放通、邀请链接与待审批申请一并失效(断线重连不会走到这里)
|
||||
m.state.forgetTunnel(tunnelID)
|
||||
if t, exists := m.tunnels[tunnelID]; exists {
|
||||
t.Close()
|
||||
delete(m.portMap, t.ListenPort())
|
||||
delete(m.tunnels, tunnelID)
|
||||
log.Printf("[Server] Unregistered tunnel '%s' (port %d freed)", t.Name(), t.ListenPort())
|
||||
}
|
||||
}
|
||||
|
||||
// updateTunnel 端口不变时原地更新(不断开现有连接);端口变化时先绑定新端口再关闭旧端口,失败则保留旧通道
|
||||
func (m *Manager) updateTunnel(as *agentSession, cfg protocol.TunnelConfig) protocol.RegisterRespMessage {
|
||||
m.mu.Lock()
|
||||
t, exists := m.tunnels[cfg.ID]
|
||||
if !exists || m.active != as {
|
||||
m.mu.Unlock()
|
||||
return m.registerTunnel(as, cfg)
|
||||
}
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if t.ListenPort() == cfg.ListenPort {
|
||||
t.UpdateConfig(cfg)
|
||||
log.Printf("[Server] Updated tunnel '%s' whitelist/metadata", cfg.Name)
|
||||
return registerResp(cfg, nil)
|
||||
}
|
||||
|
||||
if err := m.checkPortLocked(cfg); err != nil {
|
||||
return registerResp(cfg, err)
|
||||
}
|
||||
nt, err := newTunnel(cfg, as.sess, m.addSecEvent, m.onClientConnect, t)
|
||||
if err != nil {
|
||||
return registerResp(cfg, err)
|
||||
}
|
||||
t.Close()
|
||||
delete(m.portMap, t.ListenPort())
|
||||
m.tunnels[cfg.ID] = nt
|
||||
m.portMap[cfg.ListenPort] = cfg.ID
|
||||
log.Printf("[Server] Moved tunnel '%s' from port %d to %d", cfg.Name, t.ListenPort(), cfg.ListenPort)
|
||||
return registerResp(cfg, nil)
|
||||
}
|
||||
|
||||
func (m *Manager) killConn(connID string) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, t := range m.tunnels {
|
||||
if t.KillConn(connID) {
|
||||
log.Printf("[Server] Killed connection %s", connID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) revokeLease(tunnelID, ip string) {
|
||||
m.mu.RLock()
|
||||
t := m.tunnels[tunnelID]
|
||||
m.mu.RUnlock()
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
revoked, kicked := t.RevokeDynamic(ip)
|
||||
m.state.removeLease(tunnelID, ip)
|
||||
target := ip
|
||||
if target == "" {
|
||||
target = "all"
|
||||
}
|
||||
log.Printf("[WebAuth] Revoked %d temporary lease(s) (%s) on tunnel %s, disconnected %d client(s)",
|
||||
revoked, target, t.Name(), kicked)
|
||||
}
|
||||
|
||||
func (m *Manager) tunnel(id string) *Tunnel {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.tunnels[id]
|
||||
}
|
||||
|
||||
func (m *Manager) createInvite(msg protocol.CreateInviteMessage) protocol.InviteRespMessage {
|
||||
resp := protocol.InviteRespMessage{Type: protocol.TypeInviteResp, RequestID: msg.RequestID}
|
||||
t := m.tunnel(msg.TunnelID)
|
||||
switch {
|
||||
case t == nil:
|
||||
resp.Error = "通道未开启,无法生成邀请链接"
|
||||
case msg.ValidMinutes <= 0 || msg.ValidMinutes > 30*24*60:
|
||||
resp.Error = "邀请有效期须在 1 分钟到 30 天之间"
|
||||
case msg.MaxUses < 0:
|
||||
resp.Error = "可用设备数不能为负数"
|
||||
case !protocol.ValidLeaseDuration(msg.LeaseDuration):
|
||||
resp.Error = "无效的授权时长"
|
||||
default:
|
||||
resp.Invite = m.state.createInvite(msg.TunnelID, truncateRunes(msg.Note, 64), time.Duration(msg.ValidMinutes)*time.Minute, msg.MaxUses, msg.LeaseDuration, msg.RequireApproval)
|
||||
resp.Success = true
|
||||
log.Printf("[WebAuth] Created invite for tunnel %s (valid %dm, max %d device(s))", t.Name(), msg.ValidMinutes, msg.MaxUses)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (m *Manager) revokeInvite(tunnelID, code string) {
|
||||
ips := m.state.revokeInvite(tunnelID, code)
|
||||
kicked := 0
|
||||
if t := m.tunnel(tunnelID); t != nil {
|
||||
for _, ip := range ips {
|
||||
_, k := t.RevokeDynamic(ip)
|
||||
kicked += k
|
||||
}
|
||||
}
|
||||
log.Printf("[WebAuth] Revoked invite on tunnel %s: %d lease(s) removed, %d client(s) disconnected", tunnelID, len(ips), kicked)
|
||||
}
|
||||
|
||||
func (m *Manager) decideRequest(tunnelID, requestID string, approve bool) {
|
||||
t := m.tunnel(tunnelID)
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
r, exp, err := m.state.decide(tunnelID, requestID, approve, t.Snapshot().ExpiresAt)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if approve {
|
||||
t.AddDynamicWhitelist(r.IP, exp)
|
||||
}
|
||||
log.Printf("[WebAuth] Request from %s (%s) on tunnel %s: %s", r.Name, r.IP, t.Name(), r.Status)
|
||||
}
|
||||
|
||||
// onClientConnect starts the countdown of a device's lease on its first connection
|
||||
func (m *Manager) onClientConnect(tunnelID, ip string) {
|
||||
t := m.tunnel(tunnelID)
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
if exp, started := m.state.startLease(tunnelID, ip, t.Snapshot().ExpiresAt, time.Now()); started {
|
||||
t.AddDynamicWhitelist(ip, exp)
|
||||
log.Printf("[WebAuth] %s connected for the first time on tunnel %s, access valid until %s", ip, t.Name(), exp.Format("01-02 15:04"))
|
||||
}
|
||||
}
|
||||
|
||||
func truncateRunes(s string, n int) string {
|
||||
r := []rune(strings.TrimSpace(s))
|
||||
if len(r) > n {
|
||||
r = r[:n]
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
|
||||
func (m *Manager) addSecEvent(ev protocol.SecurityEvent) {
|
||||
m.secEventsMu.Lock()
|
||||
defer m.secEventsMu.Unlock()
|
||||
m.secEvents = append(m.secEvents, ev)
|
||||
if len(m.secEvents) > 100 {
|
||||
m.secEvents = m.secEvents[len(m.secEvents)-100:]
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) metricsLoop(as *agentSession) {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-as.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
if m.active != as {
|
||||
m.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
tunnelMetricsList := make([]protocol.TunnelMetrics, 0, len(m.tunnels))
|
||||
allActiveConns := make([]protocol.ActiveConnInfo, 0)
|
||||
for _, t := range m.tunnels {
|
||||
// 授权到期(或白名单变更)后,已建立的连接也要断开,“授权后可访问 X”才名副其实
|
||||
for _, addr := range t.EnforceAccess() {
|
||||
log.Printf("[Tunnel %d] access expired, disconnected %s", t.ListenPort(), addr)
|
||||
}
|
||||
t.Tick()
|
||||
tm, conns := t.Metrics()
|
||||
tm.DynamicLeases = m.state.leaseList(tm.TunnelID)
|
||||
tm.Invites = m.state.inviteList(tm.TunnelID)
|
||||
tm.Pending = m.state.pendingList(tm.TunnelID)
|
||||
tunnelMetricsList = append(tunnelMetricsList, tm)
|
||||
allActiveConns = append(allActiveConns, conns...)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
m.secEventsMu.Lock()
|
||||
secCopy := make([]protocol.SecurityEvent, len(m.secEvents))
|
||||
copy(secCopy, m.secEvents)
|
||||
m.secEventsMu.Unlock()
|
||||
|
||||
as.send(protocol.MetricsMessage{
|
||||
Type: protocol.TypeMetrics,
|
||||
Tunnels: tunnelMetricsList,
|
||||
ActiveConns: allActiveConns,
|
||||
SecurityEvents: secCopy,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type httpResponseWriter struct {
|
||||
conn net.Conn
|
||||
header http.Header
|
||||
statusCode int
|
||||
wroteHdr bool
|
||||
}
|
||||
|
||||
func (w *httpResponseWriter) Header() http.Header {
|
||||
return w.header
|
||||
}
|
||||
|
||||
func (w *httpResponseWriter) WriteHeader(statusCode int) {
|
||||
if !w.wroteHdr {
|
||||
w.statusCode = statusCode
|
||||
w.wroteHdr = true
|
||||
fmt.Fprintf(w.conn, "HTTP/1.1 %d %s\r\n", statusCode, http.StatusText(statusCode))
|
||||
_ = w.header.Write(w.conn)
|
||||
fmt.Fprintf(w.conn, "\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (w *httpResponseWriter) Write(b []byte) (int, error) {
|
||||
if !w.wroteHdr {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return w.conn.Write(b)
|
||||
}
|
||||
|
||||
// HandleHTTP handles incoming HTTP requests (such as /auth?code=... and /health)
|
||||
func (m *Manager) HandleHTTP(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
// 单个请求出错不能拖垮整个服务进程
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[Server] PANIC while handling HTTP request: %v", r)
|
||||
}
|
||||
}()
|
||||
_ = conn.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.RemoteAddr = conn.RemoteAddr().String()
|
||||
if m.TrustProxyHeaders {
|
||||
if xff := req.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
if first := strings.TrimSpace(strings.Split(xff, ",")[0]); first != "" {
|
||||
req.RemoteAddr = first
|
||||
}
|
||||
} else if xrip := strings.TrimSpace(req.Header.Get("X-Real-IP")); xrip != "" {
|
||||
req.RemoteAddr = xrip
|
||||
}
|
||||
}
|
||||
|
||||
w := &httpResponseWriter{
|
||||
conn: conn,
|
||||
header: make(http.Header),
|
||||
}
|
||||
w.header.Set("Server", "PortRelay")
|
||||
w.header.Set("Connection", "close")
|
||||
|
||||
m.handleHTTPRequest(w, req)
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/yamux"
|
||||
|
||||
"github.com/DeepFal/PortRelay/core/protocol"
|
||||
)
|
||||
|
||||
const testToken = "secret"
|
||||
|
||||
type peekedConn struct {
|
||||
net.Conn
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
func (c *peekedConn) Read(b []byte) (int, error) { return c.r.Read(b) }
|
||||
|
||||
// startServer 复刻 cmd/portrelay-server 的分流逻辑
|
||||
func startServer(t *testing.T, m *Manager) 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(c net.Conn) {
|
||||
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
br := bufio.NewReader(c)
|
||||
p, err := br.Peek(3)
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
_ = c.SetReadDeadline(time.Time{})
|
||||
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()
|
||||
}
|
||||
|
||||
type testAgent struct {
|
||||
sess *yamux.Session
|
||||
w *bufio.Writer
|
||||
msgs chan []byte
|
||||
}
|
||||
|
||||
// dialAgent 连接服务端、发送鉴权,并对每条数据流回复 "OK"
|
||||
func dialAgent(t *testing.T, addr, token string) *testAgent {
|
||||
t.Helper()
|
||||
c, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := yamux.DefaultConfig()
|
||||
cfg.LogOutput = io.Discard
|
||||
s, err := yamux.Client(c, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cs, err := s.OpenStream()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &testAgent{sess: s, w: bufio.NewWriter(cs), msgs: make(chan []byte, 64)}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
go func() {
|
||||
r := bufio.NewReader(cs)
|
||||
for {
|
||||
line, err := r.ReadBytes('\n')
|
||||
if err != nil {
|
||||
close(a.msgs)
|
||||
return
|
||||
}
|
||||
var base protocol.BaseMessage
|
||||
if json.Unmarshal(line, &base) == nil && base.Type != protocol.TypeMetrics {
|
||||
a.msgs <- line
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
for {
|
||||
st, err := s.AcceptStream()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
// 回复 OK 后保持连接,直到对端关闭(便于验证服务端主动断开)
|
||||
r := bufio.NewReader(st)
|
||||
_, _ = r.ReadBytes('\n')
|
||||
_, _ = st.Write([]byte("OK"))
|
||||
_, _ = io.Copy(io.Discard, r)
|
||||
st.Close()
|
||||
}()
|
||||
}
|
||||
}()
|
||||
a.send(t, protocol.AuthMessage{Type: protocol.TypeAuth, Token: token})
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *testAgent) send(t *testing.T, v any) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(v)
|
||||
_, _ = a.w.Write(append(b, '\n'))
|
||||
if err := a.w.Flush(); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *testAgent) next(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
select {
|
||||
case m, ok := <-a.msgs:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for control message")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *testAgent) expectAuth(t *testing.T) protocol.AuthRespMessage {
|
||||
t.Helper()
|
||||
var resp protocol.AuthRespMessage
|
||||
if line := a.next(t); line != nil {
|
||||
_ = json.Unmarshal(line, &resp)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (a *testAgent) register(t *testing.T, msgType string, cfg protocol.TunnelConfig) protocol.RegisterRespMessage {
|
||||
t.Helper()
|
||||
if msgType == protocol.TypeUpdateTunnel {
|
||||
a.send(t, protocol.UpdateTunnelMessage{Type: msgType, Tunnel: cfg})
|
||||
} else {
|
||||
a.send(t, protocol.RegisterTunnelMessage{Type: msgType, Tunnel: cfg})
|
||||
}
|
||||
var resp protocol.RegisterRespMessage
|
||||
_ = json.Unmarshal(a.next(t), &resp)
|
||||
return resp
|
||||
}
|
||||
|
||||
// createInvite asks the server for an invite through the control stream
|
||||
func (a *testAgent) createInvite(t *testing.T, tunnelID string, maxUses int) protocol.Invite {
|
||||
t.Helper()
|
||||
return a.createInviteWith(t, tunnelID, maxUses, "")
|
||||
}
|
||||
|
||||
func (a *testAgent) createInviteWith(t *testing.T, tunnelID string, maxUses int, lease string) protocol.Invite {
|
||||
t.Helper()
|
||||
return a.createInviteFull(t, tunnelID, maxUses, lease, false)
|
||||
}
|
||||
|
||||
func (a *testAgent) createInviteFull(t *testing.T, tunnelID string, maxUses int, lease string, approval bool) protocol.Invite {
|
||||
t.Helper()
|
||||
a.send(t, protocol.CreateInviteMessage{Type: protocol.TypeCreateInvite, RequestID: "r1", TunnelID: tunnelID, ValidMinutes: 60, MaxUses: maxUses, LeaseDuration: lease, RequireApproval: approval})
|
||||
var resp protocol.InviteRespMessage
|
||||
_ = json.Unmarshal(a.next(t), &resp)
|
||||
if !resp.Success {
|
||||
t.Fatalf("create invite failed: %s", resp.Error)
|
||||
}
|
||||
return resp.Invite
|
||||
}
|
||||
|
||||
var noRedirect = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
|
||||
func postForm(t *testing.T, addr, code, name string) (int, string, string) {
|
||||
t.Helper()
|
||||
resp, err := noRedirect.PostForm("http://"+addr+"/auth", url.Values{"code": {code}, "name": {name}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, resp.Header.Get("Location"), string(b)
|
||||
}
|
||||
|
||||
// freePort picks a port outside the OS ephemeral range (so outgoing connections cannot
|
||||
// grab it between the check and the tunnel's bind) and verifies it is bindable on 0.0.0.0.
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
start := 20000 + rand.Intn(9000)
|
||||
for i := 0; i < 1000; i++ {
|
||||
p := 20000 + (start-20000+i)%9000
|
||||
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")
|
||||
return 0
|
||||
}
|
||||
|
||||
func newTestManager() *Manager {
|
||||
return NewManager(1024, 65535, testToken)
|
||||
}
|
||||
|
||||
func tunnelCfg(id string, port int) protocol.TunnelConfig {
|
||||
return protocol.TunnelConfig{ID: id, Name: id, ListenPort: port, Whitelist: []string{"*"}, Enabled: true, AuthCode: "code-" + id}
|
||||
}
|
||||
|
||||
func hit(port int) string {
|
||||
c, err := net.DialTimeout("tcp", "127.0.0.1:"+strconv.Itoa(port), time.Second)
|
||||
if err != nil {
|
||||
return "dial error"
|
||||
}
|
||||
defer c.Close()
|
||||
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
b := make([]byte, 2)
|
||||
n, _ := io.ReadFull(c, b)
|
||||
return string(b[:n])
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not met in time")
|
||||
}
|
||||
|
||||
func TestWrongTokenIsRejected(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, "WRONG")
|
||||
if resp := a.expectAuth(t); resp.Success {
|
||||
t.Fatal("auth with wrong token succeeded")
|
||||
}
|
||||
port := freePort(t)
|
||||
// 服务端在鉴权失败后会关闭连接,这里的写入允许失败
|
||||
b, _ := json.Marshal(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: tunnelCfg("evil", port)})
|
||||
_, _ = a.w.Write(append(b, '\n'))
|
||||
_ = a.w.Flush()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if got := hit(port); got == "OK" {
|
||||
t.Fatal("unauthenticated agent managed to open a tunnel")
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if len(m.tunnels) != 0 {
|
||||
t.Fatalf("expected no tunnels, got %d", len(m.tunnels))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortProbeDoesNotBreakActiveSession(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
if !a.expectAuth(t).Success {
|
||||
t.Fatal("auth failed")
|
||||
}
|
||||
|
||||
// 模拟 nc -z、端口扫描、非 HTTP 垃圾数据
|
||||
c, _ := net.Dial("tcp", addr)
|
||||
c.Close()
|
||||
c, _ = net.Dial("tcp", addr)
|
||||
_, _ = c.Write([]byte("SSH-2.0-scanner\r\n"))
|
||||
c.Close()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
port := freePort(t)
|
||||
if resp := a.register(t, protocol.TypeRegisterTunnel, tunnelCfg("b", port)); !resp.Success {
|
||||
t.Fatalf("register failed: %s", resp.Error)
|
||||
}
|
||||
if got := hit(port); got != "OK" {
|
||||
t.Fatalf("tunnel registered after port probe is broken: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleSessionTeardownKeepsNewSessionTunnels(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
|
||||
old := dialAgent(t, addr, testToken)
|
||||
if !old.expectAuth(t).Success {
|
||||
t.Fatal("auth failed")
|
||||
}
|
||||
port := freePort(t)
|
||||
if resp := old.register(t, protocol.TypeRegisterTunnel, tunnelCfg("a", port)); !resp.Success {
|
||||
t.Fatal(resp.Error)
|
||||
}
|
||||
|
||||
// 唤醒后 Agent 立刻重连并重新注册,旧会话随后才断开
|
||||
fresh := dialAgent(t, addr, testToken)
|
||||
if !fresh.expectAuth(t).Success {
|
||||
t.Fatal("auth failed")
|
||||
}
|
||||
if resp := fresh.register(t, protocol.TypeRegisterTunnel, tunnelCfg("a", port)); !resp.Success {
|
||||
t.Fatalf("re-register on new session failed: %s", resp.Error)
|
||||
}
|
||||
old.sess.Close()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
if got := hit(port); got != "OK" {
|
||||
t.Fatalf("tunnel lost after stale session teardown: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateKeepsConnectionsAndLeases(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
|
||||
port := freePort(t)
|
||||
cfg := tunnelCfg("a", port)
|
||||
a.register(t, protocol.TypeRegisterTunnel, cfg)
|
||||
|
||||
m.mu.RLock()
|
||||
tun := m.tunnels["a"]
|
||||
m.mu.RUnlock()
|
||||
tun.AddDynamicWhitelist("10.1.2.3", time.Now().Add(time.Hour))
|
||||
|
||||
cfg.Name = "renamed"
|
||||
cfg.Whitelist = []string{"127.0.0.1"}
|
||||
if resp := a.register(t, protocol.TypeUpdateTunnel, cfg); !resp.Success {
|
||||
t.Fatal(resp.Error)
|
||||
}
|
||||
m.mu.RLock()
|
||||
same := m.tunnels["a"] == tun
|
||||
m.mu.RUnlock()
|
||||
if !same {
|
||||
t.Fatal("metadata update recreated the tunnel (would drop active connections)")
|
||||
}
|
||||
if got := tun.GetDynamicWhitelist(); len(got) != 1 || got[0] != "10.1.2.3" {
|
||||
t.Fatalf("dynamic lease lost after update: %v", got)
|
||||
}
|
||||
if hit(port) != "OK" {
|
||||
t.Fatal("tunnel unusable after update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePortConflictKeepsOldTunnel(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
|
||||
p1, p2 := freePort(t), freePort(t)
|
||||
a.register(t, protocol.TypeRegisterTunnel, tunnelCfg("a", p1))
|
||||
a.register(t, protocol.TypeRegisterTunnel, tunnelCfg("b", p2))
|
||||
|
||||
moved := tunnelCfg("a", p2)
|
||||
if resp := a.register(t, protocol.TypeUpdateTunnel, moved); resp.Success {
|
||||
t.Fatal("moving onto an occupied port should fail")
|
||||
}
|
||||
if hit(p1) != "OK" {
|
||||
t.Fatal("failed port change destroyed the original tunnel")
|
||||
}
|
||||
|
||||
p3 := freePort(t)
|
||||
if resp := a.register(t, protocol.TypeUpdateTunnel, tunnelCfg("a", p3)); !resp.Success {
|
||||
t.Fatal(resp.Error)
|
||||
}
|
||||
if hit(p3) != "OK" {
|
||||
t.Fatal("tunnel not reachable on new port")
|
||||
}
|
||||
if hit(p1) == "OK" {
|
||||
t.Fatal("old port still served after move")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateOfUnknownTunnelRegistersIt(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
port := freePort(t)
|
||||
if resp := a.register(t, protocol.TypeUpdateTunnel, tunnelCfg("x", port)); !resp.Success {
|
||||
t.Fatal(resp.Error)
|
||||
}
|
||||
if hit(port) != "OK" {
|
||||
t.Fatal("update of unknown tunnel did not register it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectCleansUpTunnels(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
port := freePort(t)
|
||||
a.register(t, protocol.TypeRegisterTunnel, tunnelCfg("a", port))
|
||||
a.sess.Close()
|
||||
waitFor(t, func() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.active == nil && len(m.tunnels) == 0
|
||||
})
|
||||
}
|
||||
|
||||
func httpGet(t *testing.T, addr, path string, hdr map[string]string) (int, string) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", "http://"+addr+path, nil)
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
func TestInviteFlowIgnoresForwardedHeadersAndEscapesHTML(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
cfg := tunnelCfg("a", freePort(t))
|
||||
cfg.Name = `<script>alert(1)</script>`
|
||||
cfg.Whitelist = nil // 仅授权链接
|
||||
a.register(t, protocol.TypeRegisterTunnel, cfg)
|
||||
inv := a.createInvite(t, "a", 1)
|
||||
|
||||
if hit(cfg.ListenPort) == "OK" {
|
||||
t.Fatal("link-only tunnel must reject unauthorised clients")
|
||||
}
|
||||
|
||||
status, body := httpGet(t, addr, "/i/"+inv.Code, map[string]string{"X-Forwarded-For": "8.8.8.8"})
|
||||
if status != 200 || !strings.Contains(body, `name="name"`) {
|
||||
t.Fatalf("invite form not shown: %d", status)
|
||||
}
|
||||
if strings.Contains(body, "<script>") {
|
||||
t.Fatal("tunnel name rendered without escaping")
|
||||
}
|
||||
|
||||
status, _, body = postForm(t, addr, inv.Code, `<b>张三</b>`)
|
||||
if status != 200 || !strings.Contains(body, "授权成功") {
|
||||
t.Fatalf("submit failed: %d %s", status, body)
|
||||
}
|
||||
if strings.Contains(body, "<b>张三</b>") {
|
||||
t.Fatal("name rendered without escaping")
|
||||
}
|
||||
leases := m.state.leaseList("a")
|
||||
if len(leases) != 1 || leases[0].IP != "127.0.0.1" || leases[0].Name != "<b>张三</b>" || leases[0].Invite != inv.Code {
|
||||
t.Fatalf("unexpected leases (X-Forwarded-For must be ignored): %+v", leases)
|
||||
}
|
||||
if hit(cfg.ListenPort) != "OK" {
|
||||
t.Fatal("authorised client cannot connect")
|
||||
}
|
||||
|
||||
// 同一设备再次打开同一链接不占用新名额
|
||||
if status, _, _ := postForm(t, addr, inv.Code, "张三"); status != 200 {
|
||||
t.Fatalf("same device reuse rejected: %d", status)
|
||||
}
|
||||
if uses := m.state.inviteList("a")[0].Uses; len(uses) != 1 {
|
||||
t.Fatalf("reuse by same device consumed another slot: %+v", uses)
|
||||
}
|
||||
|
||||
if status, _ := httpGet(t, addr, "/i/wrongcode", nil); status != 404 {
|
||||
t.Fatalf("wrong code: status %d", status)
|
||||
}
|
||||
if status, _, _ := postForm(t, addr, inv.Code, " "); status != 400 {
|
||||
t.Fatalf("empty name accepted: %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyFixedAuthCodeNoLongerWorks(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
cfg := tunnelCfg("a", freePort(t))
|
||||
a.register(t, protocol.TypeRegisterTunnel, cfg)
|
||||
if status, _ := httpGet(t, addr, "/auth?code="+cfg.AuthCode, nil); status != 404 {
|
||||
t.Fatalf("legacy fixed code should be rejected, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalFlow(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
cfg := tunnelCfg("a", freePort(t))
|
||||
cfg.Whitelist = nil
|
||||
a.register(t, protocol.TypeRegisterTunnel, cfg)
|
||||
inv := a.createInviteFull(t, "a", 1, "", true)
|
||||
|
||||
status, loc, _ := postForm(t, addr, inv.Code, "李四")
|
||||
if status != http.StatusSeeOther || !strings.HasPrefix(loc, "/auth/status?id=") {
|
||||
t.Fatalf("expected redirect to status page, got %d %q", status, loc)
|
||||
}
|
||||
if _, body := httpGet(t, addr, loc, nil); !strings.Contains(body, "等待管理员批准") {
|
||||
t.Fatal("status page should show pending")
|
||||
}
|
||||
if hit(cfg.ListenPort) == "OK" {
|
||||
t.Fatal("pending request must not grant access")
|
||||
}
|
||||
pending := m.state.pendingList("a")
|
||||
if len(pending) != 1 || pending[0].Name != "李四" {
|
||||
t.Fatalf("pending list: %+v", pending)
|
||||
}
|
||||
|
||||
a.send(t, protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, TunnelID: "a", RequestID: pending[0].ID, Approve: true})
|
||||
waitFor(t, func() bool { return len(m.state.leaseList("a")) == 1 })
|
||||
if _, body := httpGet(t, addr, loc, nil); !strings.Contains(body, "申请已批准") {
|
||||
t.Fatal("status page should show approval")
|
||||
}
|
||||
if hit(cfg.ListenPort) != "OK" {
|
||||
t.Fatal("approved client cannot connect")
|
||||
}
|
||||
|
||||
// 拒绝流程
|
||||
inv2 := a.createInviteFull(t, "a", 1, "", true)
|
||||
m.state.removeLease("a", "")
|
||||
_, loc2, _ := postForm(t, addr, inv2.Code, "王五")
|
||||
id := strings.TrimPrefix(loc2, "/auth/status?id=")
|
||||
a.send(t, protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, TunnelID: "a", RequestID: id, Approve: false})
|
||||
waitFor(t, func() bool {
|
||||
r, ok := m.state.request(id)
|
||||
return ok && r.Status == "rejected"
|
||||
})
|
||||
if _, body := httpGet(t, addr, loc2, nil); !strings.Contains(body, "申请未通过") {
|
||||
t.Fatal("status page should show rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeInviteRemovesItsLeases(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
cfg := tunnelCfg("a", freePort(t))
|
||||
cfg.Whitelist = nil
|
||||
a.register(t, protocol.TypeRegisterTunnel, cfg)
|
||||
inv := a.createInvite(t, "a", 0)
|
||||
postForm(t, addr, inv.Code, "张三")
|
||||
if hit(cfg.ListenPort) != "OK" {
|
||||
t.Fatal("grant failed")
|
||||
}
|
||||
a.send(t, protocol.RevokeInviteMessage{Type: protocol.TypeRevokeInvite, TunnelID: "a", Code: inv.Code})
|
||||
waitFor(t, func() bool { return len(m.state.leaseList("a")) == 0 && len(m.state.inviteList("a")) == 0 })
|
||||
if hit(cfg.ListenPort) == "OK" {
|
||||
t.Fatal("leases of revoked invite still active")
|
||||
}
|
||||
if status, _ := httpGet(t, addr, "/i/"+inv.Code, nil); status != 404 {
|
||||
t.Fatal("revoked invite still usable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalIsPerInvite(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
cfg := tunnelCfg("a", freePort(t))
|
||||
cfg.Whitelist = nil
|
||||
a.register(t, protocol.TypeRegisterTunnel, cfg)
|
||||
|
||||
// 同一通道:需要审批的邀请进入待审批,不需要审批的直接放通
|
||||
needs := a.createInviteFull(t, "a", 1, "", true)
|
||||
direct := a.createInviteFull(t, "a", 1, "", false)
|
||||
if status, loc, _ := postForm(t, addr, needs.Code, "甲"); status != http.StatusSeeOther || !strings.HasPrefix(loc, "/auth/status") {
|
||||
t.Fatalf("approval invite should go to pending, got %d %q", status, loc)
|
||||
}
|
||||
if hit(cfg.ListenPort) == "OK" {
|
||||
t.Fatal("pending request granted access")
|
||||
}
|
||||
m.state.removeLease("a", "")
|
||||
if status, _, body := postForm(t, addr, direct.Code, "乙"); status != 200 || !strings.Contains(body, "授权成功") {
|
||||
t.Fatalf("direct invite should grant immediately, got %d", status)
|
||||
}
|
||||
if hit(cfg.ListenPort) != "OK" {
|
||||
t.Fatal("direct invite did not grant access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantAfterRevokeAllDoesNotPanic(t *testing.T) {
|
||||
s := newStateStore()
|
||||
inv := s.createInvite("a", "", time.Hour, 0, "", false)
|
||||
if _, err := s.useInviteAndGrant("a", inv.Code, "甲", "10.0.0.1", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.removeLease("a", "") // 全部撤销
|
||||
if _, err := s.useInviteAndGrant("a", inv.Code, "乙", "10.0.0.2", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l := s.leaseList("a"); len(l) != 1 || l[0].IP != "10.0.0.2" {
|
||||
t.Fatalf("grant after revoke-all failed: %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredLeaseDisconnectsActiveConnection(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
cfg := tunnelCfg("a", freePort(t))
|
||||
cfg.Whitelist = nil
|
||||
a.register(t, protocol.TypeRegisterTunnel, cfg)
|
||||
|
||||
m.mu.RLock()
|
||||
tun := m.tunnels["a"]
|
||||
m.mu.RUnlock()
|
||||
tun.AddDynamicWhitelist("127.0.0.1", time.Now().Add(1500*time.Millisecond))
|
||||
|
||||
c, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.ListenPort))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
buf := make([]byte, 2)
|
||||
_ = c.SetReadDeadline(time.Now().Add(time.Second))
|
||||
if _, err := io.ReadFull(c, buf); err != nil {
|
||||
t.Fatalf("leased client should connect: %v", err)
|
||||
}
|
||||
|
||||
// 授权到期后,连接应在下一次检查(约 1 秒)内被断开
|
||||
_ = c.SetReadDeadline(time.Now().Add(4 * time.Second))
|
||||
start := time.Now()
|
||||
_, err = c.Read(buf)
|
||||
if err == nil {
|
||||
t.Fatal("connection still alive after lease expired")
|
||||
}
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
t.Fatalf("connection was not closed within %v of lease expiry", time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaseCountdownStartsAtFirstConnection(t *testing.T) {
|
||||
s := newStateStore()
|
||||
now := time.Now()
|
||||
inv := s.createInvite("a", "", time.Hour, 1, protocol.LeaseDuration2h, false)
|
||||
l, err := s.useInviteAndGrant("a", inv.Code, "甲", "10.0.0.1", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !l.Deferred || l.StartedAt != nil || l.ExpiresAt.Sub(now) < 23*time.Hour {
|
||||
t.Fatalf("new lease should wait for first connection within 24h: %+v", l)
|
||||
}
|
||||
// 1 小时后才首次连接:从那时起算 2 小时
|
||||
later := now.Add(time.Hour)
|
||||
exp, started := s.startLease("a", "10.0.0.1", nil, later)
|
||||
if !started || !exp.Equal(later.Add(2*time.Hour)) {
|
||||
t.Fatalf("countdown should start at first connection: started=%v exp=%v", started, exp)
|
||||
}
|
||||
// 再次连接不会重置计时
|
||||
if _, again := s.startLease("a", "10.0.0.1", nil, later.Add(30*time.Minute)); again {
|
||||
t.Fatal("countdown must only start once")
|
||||
}
|
||||
// 通道开启期间有效:立即开始,无需等待
|
||||
inv2 := s.createInvite("a", "", time.Hour, 1, protocol.LeaseDurationTunnel, false)
|
||||
l2, _ := s.useInviteAndGrant("a", inv2.Code, "乙", "10.0.0.2", nil)
|
||||
if l2.Deferred {
|
||||
t.Fatal("tunnel-lifetime lease should start immediately")
|
||||
}
|
||||
// 首次连接截止时间不超过通道自身到期时间
|
||||
tunnelEnd := now.Add(3 * time.Hour)
|
||||
inv3 := s.createInvite("a", "", time.Hour, 1, "", false)
|
||||
l3, _ := s.useInviteAndGrant("a", inv3.Code, "丙", "10.0.0.3", &tunnelEnd)
|
||||
if !l3.ExpiresAt.Equal(tunnelEnd) {
|
||||
t.Fatalf("start window should be capped by tunnel TTL: %v", l3.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteCapacityAndExpiry(t *testing.T) {
|
||||
s := newStateStore()
|
||||
inv := s.createInvite("a", "", time.Hour, 1, "", false)
|
||||
if _, err := s.useInviteAndGrant("a", inv.Code, "甲", "10.0.0.1", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.useInviteAndGrant("a", inv.Code, "甲", "10.0.0.1", nil); err != nil {
|
||||
t.Fatalf("same device should be able to reuse: %v", err)
|
||||
}
|
||||
if _, err := s.useInviteAndGrant("a", inv.Code, "乙", "10.0.0.2", nil); err != errInviteUsedUp {
|
||||
t.Fatalf("second device should be rejected, got %v", err)
|
||||
}
|
||||
// 待审批的申请也占用名额
|
||||
inv2 := s.createInvite("a", "", time.Hour, 1, "", false)
|
||||
if _, err := s.addRequest("a", inv2.Code, "丙", "10.0.0.3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.addRequest("a", inv2.Code, "丁", "10.0.0.4"); err != errInviteUsedUp {
|
||||
t.Fatalf("pending request should hold the slot, got %v", err)
|
||||
}
|
||||
|
||||
expired := s.createInvite("a", "", -time.Minute, 0, "", false)
|
||||
if _, _, ok := s.findInvite(expired.Code); ok {
|
||||
t.Fatal("expired invite must not be found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaseExpiryModes(t *testing.T) {
|
||||
now := time.Date(2026, 10, 13, 10, 0, 0, 0, time.Local)
|
||||
cases := map[string]time.Time{
|
||||
"": now.Add(2 * time.Hour),
|
||||
protocol.LeaseDuration1h: now.Add(time.Hour),
|
||||
protocol.LeaseDuration8h: now.Add(8 * time.Hour),
|
||||
protocol.LeaseDurationToday: time.Date(2026, 10, 13, 23, 59, 59, 0, time.Local),
|
||||
protocol.LeaseDurationTunnel: now.Add(30 * 24 * time.Hour),
|
||||
}
|
||||
for mode, want := range cases {
|
||||
if got := leaseExpiry(mode, nil, now); !got.Equal(want) {
|
||||
t.Errorf("mode %q: got %v want %v", mode, got, want)
|
||||
}
|
||||
}
|
||||
// 不超过通道自身的到期时间
|
||||
tunnelEnd := now.Add(time.Hour)
|
||||
if got := leaseExpiry(protocol.LeaseDuration8h, &tunnelEnd, now); !got.Equal(tunnelEnd) {
|
||||
t.Errorf("lease outlives tunnel TTL: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaseDurationComesFromInvite(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
|
||||
// 直接放通:同一通道上两个邀请各自的时长
|
||||
cfg := tunnelCfg("a", freePort(t))
|
||||
cfg.Whitelist = nil
|
||||
a.register(t, protocol.TypeRegisterTunnel, cfg)
|
||||
short := a.createInviteWith(t, "a", 1, protocol.LeaseDuration1h)
|
||||
long := a.createInviteWith(t, "a", 1, protocol.LeaseDurationToday)
|
||||
if short.LeaseDuration != "1h" || long.LeaseDuration != "today" {
|
||||
t.Fatalf("invite lease durations not stored: %q %q", short.LeaseDuration, long.LeaseDuration)
|
||||
}
|
||||
if _, body := httpGet(t, addr, "/i/"+short.Code, nil); !strings.Contains(body, "1 小时") {
|
||||
t.Fatal("invite page should show the invite's own lease duration")
|
||||
}
|
||||
postForm(t, addr, short.Code, "张三")
|
||||
leases := m.state.leaseList("a")
|
||||
if len(leases) != 1 || !leases[0].AwaitingConnect || time.Until(leases[0].ExpiresAt) < 23*time.Hour {
|
||||
t.Fatalf("lease should wait for the first connection (24h window): %+v", leases)
|
||||
}
|
||||
// 首次连接后开始计时,按邀请的 1 小时计算
|
||||
if hit(cfg.ListenPort) != "OK" {
|
||||
t.Fatal("authorised client cannot connect")
|
||||
}
|
||||
waitFor(t, func() bool { l := m.state.leaseList("a"); return len(l) == 1 && !l[0].AwaitingConnect })
|
||||
leases = m.state.leaseList("a")
|
||||
if d := time.Until(leases[0].ExpiresAt); d > 61*time.Minute || d < 59*time.Minute || leases[0].StartedAt == nil {
|
||||
t.Fatalf("lease should last 1h from first connection: %+v", leases)
|
||||
}
|
||||
|
||||
// 审批:批准后的时长同样取自邀请
|
||||
cfgB := tunnelCfg("b", freePort(t))
|
||||
cfgB.Whitelist = nil
|
||||
a.register(t, protocol.TypeRegisterTunnel, cfgB)
|
||||
inv := a.createInviteFull(t, "b", 1, protocol.LeaseDuration8h, true)
|
||||
postForm(t, addr, inv.Code, "李四")
|
||||
pending := m.state.pendingList("b")
|
||||
if len(pending) != 1 {
|
||||
t.Fatal("expected a pending request")
|
||||
}
|
||||
a.send(t, protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, TunnelID: "b", RequestID: pending[0].ID, Approve: true})
|
||||
waitFor(t, func() bool { return len(m.state.leaseList("b")) == 1 })
|
||||
if !m.state.leaseList("b")[0].AwaitingConnect {
|
||||
t.Fatal("approved lease should wait for the first connection")
|
||||
}
|
||||
if hit(cfgB.ListenPort) != "OK" {
|
||||
t.Fatal("approved client cannot connect")
|
||||
}
|
||||
waitFor(t, func() bool { return !m.state.leaseList("b")[0].AwaitingConnect })
|
||||
if d := time.Until(m.state.leaseList("b")[0].ExpiresAt); d < 7*time.Hour+50*time.Minute || d > 8*time.Hour+time.Minute {
|
||||
t.Fatalf("approved lease should last 8h from first connection, got %v", d)
|
||||
}
|
||||
|
||||
// 无效时长被拒绝
|
||||
a.send(t, protocol.CreateInviteMessage{Type: protocol.TypeCreateInvite, RequestID: "bad", TunnelID: "a", ValidMinutes: 60, MaxUses: 1, LeaseDuration: "99y"})
|
||||
var resp protocol.InviteRespMessage
|
||||
_ = json.Unmarshal(a.next(t), &resp)
|
||||
if resp.Success {
|
||||
t.Fatal("invalid lease duration should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateSurvivesRestartAndLegacyFormat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
port := freePort(t)
|
||||
cfg := tunnelCfg("a", port)
|
||||
cfg.Whitelist = nil
|
||||
|
||||
state := dir + "/state.json"
|
||||
m1 := newTestManager()
|
||||
if err := m1.EnableLeasePersistence(state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr1 := startServer(t, m1)
|
||||
a1 := dialAgent(t, addr1, testToken)
|
||||
a1.expectAuth(t)
|
||||
a1.register(t, protocol.TypeRegisterTunnel, cfg)
|
||||
inv := a1.createInvite(t, "a", 2)
|
||||
postForm(t, addr1, inv.Code, "张三")
|
||||
a1.sess.Close()
|
||||
waitFor(t, func() bool {
|
||||
m1.mu.RLock()
|
||||
defer m1.mu.RUnlock()
|
||||
return len(m1.tunnels) == 0
|
||||
})
|
||||
|
||||
// 模拟服务端重启:新 Manager 读取同一状态文件,Agent 重新注册后放通与邀请仍然有效
|
||||
m2 := newTestManager()
|
||||
if err := m2.EnableLeasePersistence(state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr2 := startServer(t, m2)
|
||||
a2 := dialAgent(t, addr2, testToken)
|
||||
a2.expectAuth(t)
|
||||
if resp := a2.register(t, protocol.TypeRegisterTunnel, cfg); !resp.Success {
|
||||
t.Fatal(resp.Error)
|
||||
}
|
||||
if got := hit(port); got != "OK" {
|
||||
t.Fatalf("lease lost after restart: got %q", got)
|
||||
}
|
||||
if invs := m2.state.inviteList("a"); len(invs) != 1 || len(invs[0].Uses) != 1 || invs[0].Uses[0].Name != "张三" {
|
||||
t.Fatalf("invite lost after restart: %+v", invs)
|
||||
}
|
||||
|
||||
// 主动关闭通道后放通与邀请都失效
|
||||
a2.send(t, protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: "a"})
|
||||
a2.next(t)
|
||||
a2.register(t, protocol.TypeRegisterTunnel, cfg)
|
||||
if got := hit(port); got == "OK" {
|
||||
t.Fatal("lease should be dropped when the tunnel is explicitly closed")
|
||||
}
|
||||
if len(m2.state.inviteList("a")) != 0 {
|
||||
t.Fatal("invites should be dropped when the tunnel is explicitly closed")
|
||||
}
|
||||
|
||||
// 兼容上一版的 leases.json 格式
|
||||
legacy := dir + "/legacy.json"
|
||||
exp := time.Now().Add(time.Hour).Format(time.RFC3339Nano)
|
||||
if err := os.WriteFile(legacy, []byte(`{"t1":{"10.0.0.9":"`+exp+`"}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m3 := newTestManager()
|
||||
if err := m3.EnableLeasePersistence(legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l := m3.state.leaseList("t1"); len(l) != 1 || l[0].IP != "10.0.0.9" {
|
||||
t.Fatalf("legacy lease file not loaded: %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthPageShowsHostUsedByVisitor(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
cfg := tunnelCfg("a", freePort(t))
|
||||
a.register(t, protocol.TypeRegisterTunnel, cfg)
|
||||
inv := a.createInvite(t, "a", 1)
|
||||
|
||||
_, body := httpGet(t, addr, "/i/"+inv.Code, nil)
|
||||
if !strings.Contains(body, fmt.Sprintf("127.0.0.1:%d", cfg.ListenPort)) {
|
||||
t.Fatal("auth page should show the host the visitor used")
|
||||
}
|
||||
if strings.Contains(body, "192.168.") {
|
||||
t.Fatal("auth page must not show a hard-coded host")
|
||||
}
|
||||
}
|
||||
+604
@@ -0,0 +1,604 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DeepFal/PortRelay/core/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
// 待审批申请的有效期;已处理的申请保留同样时长,供申请人页面查询结果
|
||||
requestTTL = 30 * time.Minute
|
||||
inviteCodeLen = 10
|
||||
// leaseStartWindow 授权后需在多久内首次连接;授权时长从首次连接开始计算
|
||||
leaseStartWindow = 24 * time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
errInviteNotFound = errors.New("邀请链接无效或已过期")
|
||||
errInviteUsedUp = errors.New("该邀请链接的可用设备数已用完,请向管理员索取新的链接")
|
||||
errRequestGone = errors.New("申请不存在或已过期")
|
||||
)
|
||||
|
||||
type leaseInfo struct {
|
||||
ExpiresAt time.Time `json:"expires_at"` // 计时开始前:首次连接截止时间;开始后:授权到期时间
|
||||
Name string `json:"name,omitempty"`
|
||||
Invite string `json:"invite,omitempty"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
// Deferred 为 true 表示尚未首次连接,计时未开始
|
||||
Deferred bool `json:"deferred,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
}
|
||||
|
||||
// newLease creates a lease whose countdown starts at the device's first connection.
|
||||
// Leases valid for the whole tunnel lifetime start immediately.
|
||||
func newLease(name, invite, duration string, tunnelExpires *time.Time, now time.Time) leaseInfo {
|
||||
l := leaseInfo{Name: name, Invite: invite, Duration: duration}
|
||||
if duration == protocol.LeaseDurationTunnel {
|
||||
l.StartedAt = &now
|
||||
l.ExpiresAt = leaseExpiry(duration, tunnelExpires, now)
|
||||
return l
|
||||
}
|
||||
l.Deferred = true
|
||||
l.ExpiresAt = now.Add(leaseStartWindow)
|
||||
if tunnelExpires != nil && tunnelExpires.After(now) && tunnelExpires.Before(l.ExpiresAt) {
|
||||
l.ExpiresAt = *tunnelExpires
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// startLease starts the countdown of a deferred lease on the device's first connection.
|
||||
// Returns the new expiry and true when the lease was started now.
|
||||
func (s *stateStore) startLease(tunnelID, ip string, tunnelExpires *time.Time, now time.Time) (time.Time, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
if st == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
l, ok := st.Leases[ip]
|
||||
if !ok || !l.Deferred || !now.Before(l.ExpiresAt) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
l.Deferred = false
|
||||
l.StartedAt = &now
|
||||
l.ExpiresAt = leaseExpiry(l.Duration, tunnelExpires, now)
|
||||
st.Leases[ip] = l
|
||||
s.saveLocked()
|
||||
return l.ExpiresAt, true
|
||||
}
|
||||
|
||||
func (s *stateStore) lease(tunnelID, ip string) (leaseInfo, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if st := s.data[tunnelID]; st != nil {
|
||||
l, ok := st.Leases[ip]
|
||||
return l, ok && time.Now().Before(l.ExpiresAt)
|
||||
}
|
||||
return leaseInfo{}, false
|
||||
}
|
||||
|
||||
type tunnelState struct {
|
||||
Leases map[string]leaseInfo `json:"leases,omitempty"` // ip -> lease
|
||||
Invites map[string]*protocol.Invite `json:"invites,omitempty"` // code -> invite
|
||||
Requests map[string]*protocol.AccessRequest `json:"requests,omitempty"` // id -> request
|
||||
}
|
||||
|
||||
// stateStore keeps per-tunnel access state (leases, invites, approval requests).
|
||||
// It lives in memory and is optionally persisted to a file so it survives restarts.
|
||||
type stateStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
data map[string]*tunnelState // tunnelID -> state
|
||||
}
|
||||
|
||||
func newStateStore() *stateStore {
|
||||
return &stateStore{data: make(map[string]*tunnelState)}
|
||||
}
|
||||
|
||||
// EnableLeasePersistence loads access state from path and keeps it updated
|
||||
func (m *Manager) EnableLeasePersistence(path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
data := make(map[string]*tunnelState)
|
||||
switch {
|
||||
case err == nil:
|
||||
if err := json.Unmarshal(raw, &data); err != nil || !looksLikeState(data) {
|
||||
// 兼容旧格式:tunnelID -> ip -> 到期时间
|
||||
var legacy map[string]map[string]time.Time
|
||||
if json.Unmarshal(raw, &legacy) == nil {
|
||||
data = make(map[string]*tunnelState)
|
||||
for id, ips := range legacy {
|
||||
st := &tunnelState{Leases: make(map[string]leaseInfo)}
|
||||
for ip, exp := range ips {
|
||||
st.Leases[ip] = leaseInfo{ExpiresAt: exp}
|
||||
}
|
||||
data[id] = st
|
||||
}
|
||||
} else {
|
||||
log.Printf("[WebAuth] ignoring unreadable state file %s: %v", path, err)
|
||||
data = make(map[string]*tunnelState)
|
||||
}
|
||||
}
|
||||
case !errors.Is(err, fs.ErrNotExist):
|
||||
return err
|
||||
}
|
||||
|
||||
s := m.state
|
||||
s.mu.Lock()
|
||||
s.path = path
|
||||
s.data = data
|
||||
s.pruneLocked()
|
||||
n := 0
|
||||
for _, st := range s.data {
|
||||
n += len(st.Leases)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if n > 0 {
|
||||
log.Printf("[WebAuth] Loaded %d unexpired temporary lease(s) from %s", n, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// looksLikeState distinguishes the new format from the legacy one (both are JSON objects of objects)
|
||||
func looksLikeState(d map[string]*tunnelState) bool {
|
||||
for _, st := range d {
|
||||
if st == nil || (st.Leases == nil && st.Invites == nil && st.Requests == nil) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *stateStore) tunnelLocked(id string) *tunnelState {
|
||||
st := s.data[id]
|
||||
if st == nil {
|
||||
st = &tunnelState{}
|
||||
s.data[id] = st
|
||||
}
|
||||
if st.Leases == nil {
|
||||
st.Leases = make(map[string]leaseInfo)
|
||||
}
|
||||
if st.Invites == nil {
|
||||
st.Invites = make(map[string]*protocol.Invite)
|
||||
}
|
||||
if st.Requests == nil {
|
||||
st.Requests = make(map[string]*protocol.AccessRequest)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func randomCode(n int) string {
|
||||
const chars = "23456789abcdefghjkmnpqrstuvwxyz"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
v, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
b[i] = chars[v.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ---- leases ----
|
||||
|
||||
func (s *stateStore) grantLease(tunnelID, ip string, exp time.Time, name, invite string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.tunnelLocked(tunnelID).Leases[ip] = leaseInfo{ExpiresAt: exp, Name: name, Invite: invite}
|
||||
s.saveLocked()
|
||||
}
|
||||
|
||||
// removeLease removes one lease, or all leases of the tunnel when ip is empty
|
||||
func (s *stateStore) removeLease(tunnelID, ip string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
if st == nil {
|
||||
return
|
||||
}
|
||||
if ip == "" {
|
||||
st.Leases = make(map[string]leaseInfo)
|
||||
} else {
|
||||
delete(st.Leases, ip)
|
||||
}
|
||||
s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *stateStore) leaseExpiries(tunnelID string) map[string]time.Time {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
out := make(map[string]time.Time)
|
||||
if st := s.data[tunnelID]; st != nil {
|
||||
for ip, l := range st.Leases {
|
||||
if now.Before(l.ExpiresAt) {
|
||||
out[ip] = l.ExpiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *stateStore) leaseList(tunnelID string) []protocol.DynamicLease {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
var out []protocol.DynamicLease
|
||||
if st := s.data[tunnelID]; st != nil {
|
||||
for ip, l := range st.Leases {
|
||||
if now.Before(l.ExpiresAt) {
|
||||
out = append(out, protocol.DynamicLease{
|
||||
IP: ip, ExpiresAt: l.ExpiresAt, Name: l.Name, Invite: l.Invite,
|
||||
Duration: l.Duration, AwaitingConnect: l.Deferred, StartedAt: l.StartedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ExpiresAt.Before(out[j].ExpiresAt) })
|
||||
return out
|
||||
}
|
||||
|
||||
// forgetTunnel drops all access state of a tunnel (explicitly closed or deleted)
|
||||
func (s *stateStore) forgetTunnel(tunnelID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.data[tunnelID]; ok {
|
||||
delete(s.data, tunnelID)
|
||||
s.saveLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- invites ----
|
||||
|
||||
func (s *stateStore) createInvite(tunnelID, note string, valid time.Duration, maxUses int, leaseDuration string, requireApproval bool) protocol.Invite {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
if leaseDuration == "" {
|
||||
leaseDuration = protocol.LeaseDuration2h
|
||||
}
|
||||
inv := &protocol.Invite{
|
||||
Code: randomCode(inviteCodeLen),
|
||||
Note: note,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(valid),
|
||||
MaxUses: maxUses,
|
||||
LeaseDuration: leaseDuration,
|
||||
RequireApproval: requireApproval,
|
||||
}
|
||||
s.tunnelLocked(tunnelID).Invites[inv.Code] = inv
|
||||
s.saveLocked()
|
||||
return *inv
|
||||
}
|
||||
|
||||
// findInvite returns the tunnel owning an unexpired invite
|
||||
func (s *stateStore) findInvite(code string) (string, protocol.Invite, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
for id, st := range s.data {
|
||||
if inv, ok := st.Invites[code]; ok && now.Before(inv.ExpiresAt) {
|
||||
return id, *inv, true
|
||||
}
|
||||
}
|
||||
return "", protocol.Invite{}, false
|
||||
}
|
||||
|
||||
// capacityOKLocked reports whether ip may use the invite: devices that already used it
|
||||
// (or have a pending request through it) may come back; new devices need a free slot.
|
||||
func capacityOKLocked(st *tunnelState, inv *protocol.Invite, ip string) bool {
|
||||
if inv.MaxUses <= 0 {
|
||||
return true
|
||||
}
|
||||
devices := make(map[string]bool)
|
||||
for _, u := range inv.Uses {
|
||||
devices[u.IP] = true
|
||||
}
|
||||
for _, r := range st.Requests {
|
||||
if r.Invite == inv.Code && r.Status == "pending" {
|
||||
devices[r.IP] = true
|
||||
}
|
||||
}
|
||||
return devices[ip] || len(devices) < inv.MaxUses
|
||||
}
|
||||
|
||||
// checkInvite validates that ip may use the invite right now
|
||||
func (s *stateStore) checkInvite(tunnelID, code, ip string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
if st == nil {
|
||||
return errInviteNotFound
|
||||
}
|
||||
inv, ok := st.Invites[code]
|
||||
if !ok || !time.Now().Before(inv.ExpiresAt) {
|
||||
return errInviteNotFound
|
||||
}
|
||||
if !capacityOKLocked(st, inv, ip) {
|
||||
return errInviteUsedUp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stateStore) recordUseLocked(st *tunnelState, code, name, ip string) {
|
||||
inv, ok := st.Invites[code]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for i, u := range inv.Uses {
|
||||
if u.IP == ip {
|
||||
inv.Uses[i] = protocol.InviteUse{Name: name, IP: ip, At: time.Now()}
|
||||
return
|
||||
}
|
||||
}
|
||||
inv.Uses = append(inv.Uses, protocol.InviteUse{Name: name, IP: ip, At: time.Now()})
|
||||
}
|
||||
|
||||
// useInviteAndGrant records the use and stores the lease atomically; returns the lease
|
||||
func (s *stateStore) useInviteAndGrant(tunnelID, code, name, ip string, tunnelExpires *time.Time) (leaseInfo, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
if st == nil {
|
||||
return leaseInfo{}, errInviteNotFound
|
||||
}
|
||||
inv, ok := st.Invites[code]
|
||||
if !ok || !time.Now().Before(inv.ExpiresAt) {
|
||||
return leaseInfo{}, errInviteNotFound
|
||||
}
|
||||
if !capacityOKLocked(st, inv, ip) {
|
||||
return leaseInfo{}, errInviteUsedUp
|
||||
}
|
||||
st = s.tunnelLocked(tunnelID)
|
||||
s.recordUseLocked(st, code, name, ip)
|
||||
l := newLease(name, code, inv.LeaseDuration, tunnelExpires, time.Now())
|
||||
st.Leases[ip] = l
|
||||
s.saveLocked()
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// revokeInvite removes the invite and the leases it granted; returns the affected IPs
|
||||
func (s *stateStore) revokeInvite(tunnelID, code string) []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
delete(st.Invites, code)
|
||||
var ips []string
|
||||
for ip, l := range st.Leases {
|
||||
if l.Invite == code {
|
||||
ips = append(ips, ip)
|
||||
delete(st.Leases, ip)
|
||||
}
|
||||
}
|
||||
for id, r := range st.Requests {
|
||||
if r.Invite == code && r.Status == "pending" {
|
||||
delete(st.Requests, id)
|
||||
}
|
||||
}
|
||||
s.saveLocked()
|
||||
return ips
|
||||
}
|
||||
|
||||
func (s *stateStore) inviteList(tunnelID string) []protocol.Invite {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
var out []protocol.Invite
|
||||
if st := s.data[tunnelID]; st != nil {
|
||||
for _, inv := range st.Invites {
|
||||
if now.Before(inv.ExpiresAt) {
|
||||
c := *inv
|
||||
c.Uses = append([]protocol.InviteUse(nil), inv.Uses...)
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- approval requests ----
|
||||
|
||||
// addRequest files a pending request (re-using an existing pending one from the same IP)
|
||||
func (s *stateStore) addRequest(tunnelID, code, name, ip string) (protocol.AccessRequest, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
if st == nil {
|
||||
return protocol.AccessRequest{}, errInviteNotFound
|
||||
}
|
||||
inv, ok := st.Invites[code]
|
||||
if !ok || !time.Now().Before(inv.ExpiresAt) {
|
||||
return protocol.AccessRequest{}, errInviteNotFound
|
||||
}
|
||||
st = s.tunnelLocked(tunnelID)
|
||||
for _, r := range st.Requests {
|
||||
if r.IP == ip && r.Status == "pending" {
|
||||
r.Name = name
|
||||
s.saveLocked()
|
||||
return *r, nil
|
||||
}
|
||||
}
|
||||
if !capacityOKLocked(st, inv, ip) {
|
||||
return protocol.AccessRequest{}, errInviteUsedUp
|
||||
}
|
||||
r := &protocol.AccessRequest{
|
||||
ID: randomCode(16),
|
||||
TunnelID: tunnelID,
|
||||
Name: name,
|
||||
IP: ip,
|
||||
Invite: code,
|
||||
At: time.Now(),
|
||||
Status: "pending",
|
||||
}
|
||||
st.Requests[r.ID] = r
|
||||
s.saveLocked()
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
func (s *stateStore) request(id string) (protocol.AccessRequest, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pruneRequestsLocked()
|
||||
for _, st := range s.data {
|
||||
if r, ok := st.Requests[id]; ok {
|
||||
return *r, true
|
||||
}
|
||||
}
|
||||
return protocol.AccessRequest{}, false
|
||||
}
|
||||
|
||||
// decide marks a pending request; on approval it records the invite use and stores the lease,
|
||||
// lasting as long as the originating invite specifies (capped by the tunnel's own TTL).
|
||||
func (s *stateStore) decide(tunnelID, id string, approve bool, tunnelExpires *time.Time) (protocol.AccessRequest, time.Time, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pruneRequestsLocked()
|
||||
st := s.data[tunnelID]
|
||||
if st == nil {
|
||||
return protocol.AccessRequest{}, time.Time{}, errRequestGone
|
||||
}
|
||||
r, ok := st.Requests[id]
|
||||
if !ok || r.Status != "pending" {
|
||||
return protocol.AccessRequest{}, time.Time{}, errRequestGone
|
||||
}
|
||||
st = s.tunnelLocked(tunnelID)
|
||||
duration := ""
|
||||
if inv, ok := st.Invites[r.Invite]; ok {
|
||||
duration = inv.LeaseDuration
|
||||
}
|
||||
l := newLease(r.Name, r.Invite, duration, tunnelExpires, time.Now())
|
||||
exp := l.ExpiresAt
|
||||
r.At = time.Now()
|
||||
if approve {
|
||||
r.Status = "approved"
|
||||
s.recordUseLocked(st, r.Invite, r.Name, r.IP)
|
||||
st.Leases[r.IP] = l
|
||||
} else {
|
||||
r.Status = "rejected"
|
||||
}
|
||||
s.saveLocked()
|
||||
return *r, exp, nil
|
||||
}
|
||||
|
||||
func (s *stateStore) pendingList(tunnelID string) []protocol.AccessRequest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pruneRequestsLocked()
|
||||
var out []protocol.AccessRequest
|
||||
if st := s.data[tunnelID]; st != nil {
|
||||
for _, r := range st.Requests {
|
||||
if r.Status == "pending" {
|
||||
out = append(out, *r)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- housekeeping ----
|
||||
|
||||
func (s *stateStore) pruneRequestsLocked() {
|
||||
cutoff := time.Now().Add(-requestTTL)
|
||||
for _, st := range s.data {
|
||||
for id, r := range st.Requests {
|
||||
if r.At.Before(cutoff) {
|
||||
delete(st.Requests, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *stateStore) pruneLocked() {
|
||||
now := time.Now()
|
||||
s.pruneRequestsLocked()
|
||||
for id, st := range s.data {
|
||||
if st == nil {
|
||||
delete(s.data, id)
|
||||
continue
|
||||
}
|
||||
for ip, l := range st.Leases {
|
||||
if !now.Before(l.ExpiresAt) {
|
||||
delete(st.Leases, ip)
|
||||
}
|
||||
}
|
||||
for code, inv := range st.Invites {
|
||||
if !now.Before(inv.ExpiresAt) {
|
||||
delete(st.Invites, code)
|
||||
}
|
||||
}
|
||||
if len(st.Leases) == 0 && len(st.Invites) == 0 && len(st.Requests) == 0 {
|
||||
delete(s.data, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *stateStore) saveLocked() {
|
||||
s.pruneLocked()
|
||||
if s.path == "" {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(s.data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".state-*.json")
|
||||
if err != nil {
|
||||
log.Printf("[WebAuth] save state: %v", err)
|
||||
return
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
log.Printf("[WebAuth] save state: %v", err)
|
||||
return
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), s.path); err != nil {
|
||||
log.Printf("[WebAuth] save state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// leaseExpiry computes when a lease granted now ends, per the invite's lease duration,
|
||||
// never outliving the tunnel's own TTL.
|
||||
func leaseExpiry(duration string, tunnelExpires *time.Time, now time.Time) time.Time {
|
||||
var exp time.Time
|
||||
switch duration {
|
||||
case protocol.LeaseDuration1h:
|
||||
exp = now.Add(time.Hour)
|
||||
case protocol.LeaseDuration8h:
|
||||
exp = now.Add(8 * time.Hour)
|
||||
case protocol.LeaseDurationToday:
|
||||
y, mo, d := now.Date()
|
||||
exp = time.Date(y, mo, d, 23, 59, 59, 0, now.Location())
|
||||
case protocol.LeaseDurationTunnel:
|
||||
// 通道开启期间有效:通道被主动关闭时放通会被清除;设一个上限避免永久放通
|
||||
exp = now.Add(30 * 24 * time.Hour)
|
||||
default:
|
||||
exp = now.Add(2 * time.Hour)
|
||||
}
|
||||
if tunnelExpires != nil && tunnelExpires.After(now) && tunnelExpires.Before(exp) {
|
||||
exp = *tunnelExpires
|
||||
}
|
||||
return exp
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/yamux"
|
||||
|
||||
"github.com/DeepFal/PortRelay/core/acl"
|
||||
"github.com/DeepFal/PortRelay/core/protocol"
|
||||
"github.com/DeepFal/PortRelay/core/traffic"
|
||||
)
|
||||
|
||||
// ActiveClient records a live client TCP connection
|
||||
type ActiveClient struct {
|
||||
ID string
|
||||
ClientIP string
|
||||
ConnectedAt time.Time
|
||||
Conn net.Conn
|
||||
Meter *traffic.Meter
|
||||
}
|
||||
|
||||
// Tunnel manages a single edge listening port
|
||||
type Tunnel struct {
|
||||
Config protocol.TunnelConfig
|
||||
listener net.Listener
|
||||
whitelist *acl.Whitelist
|
||||
limiter *traffic.Limiter
|
||||
mu sync.RWMutex
|
||||
activeConns map[string]*ActiveClient
|
||||
totalConns atomic.Int64
|
||||
blockedAttemps atomic.Int64
|
||||
meter *traffic.Meter
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
session *yamux.Session
|
||||
onSecEvent func(ev protocol.SecurityEvent)
|
||||
// onConnect 在客户端通过访问控制、连接建立时调用(用于开始授权计时)
|
||||
onConnect func(tunnelID, ip string)
|
||||
}
|
||||
|
||||
// NewTunnel creates and binds a new tunnel listener
|
||||
func NewTunnel(cfg protocol.TunnelConfig, session *yamux.Session, onSecEvent func(protocol.SecurityEvent)) (*Tunnel, error) {
|
||||
return newTunnel(cfg, session, onSecEvent, nil, nil)
|
||||
}
|
||||
|
||||
// newTunnel binds a listener; when prev is set, its traffic counters and dynamic leases carry over
|
||||
// (used when the same tunnel is re-registered or moved to another port).
|
||||
func newTunnel(cfg protocol.TunnelConfig, session *yamux.Session, onSecEvent func(protocol.SecurityEvent), onConnect func(tunnelID, ip string), prev *Tunnel) (*Tunnel, error) {
|
||||
addr := fmt.Sprintf("0.0.0.0:%d", cfg.ListenPort)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to bind port %d: %w", cfg.ListenPort, err)
|
||||
}
|
||||
|
||||
t := &Tunnel{
|
||||
Config: cfg,
|
||||
listener: ln,
|
||||
whitelist: acl.NewWhitelist(cfg.Whitelist),
|
||||
limiter: traffic.NewLimiter(cfg.MaxBandwidthMB),
|
||||
activeConns: make(map[string]*ActiveClient),
|
||||
meter: traffic.NewMeter(),
|
||||
closed: make(chan struct{}),
|
||||
session: session,
|
||||
onSecEvent: onSecEvent,
|
||||
onConnect: onConnect,
|
||||
}
|
||||
if prev != nil {
|
||||
t.meter = prev.meter
|
||||
t.totalConns.Store(prev.totalConns.Load())
|
||||
t.blockedAttemps.Store(prev.blockedAttemps.Load())
|
||||
t.whitelist.RestoreDynamic(prev.dynamicLeases())
|
||||
}
|
||||
|
||||
go t.acceptLoop()
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// UpdateConfig updates whitelist, rate limiting and metadata dynamically (existing connections are kept)
|
||||
func (t *Tunnel) UpdateConfig(cfg protocol.TunnelConfig) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
dynamic := t.whitelist.DynamicLeases()
|
||||
t.Config = cfg
|
||||
t.whitelist = acl.NewWhitelist(cfg.Whitelist)
|
||||
t.whitelist.RestoreDynamic(dynamic)
|
||||
if t.limiter == nil || t.limiter.MBPerSec() != cfg.MaxBandwidthMB {
|
||||
t.limiter = traffic.NewLimiter(cfg.MaxBandwidthMB)
|
||||
}
|
||||
}
|
||||
|
||||
// ListenPort returns the bound port
|
||||
func (t *Tunnel) ListenPort() int {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return t.Config.ListenPort
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of the current tunnel config
|
||||
func (t *Tunnel) Snapshot() protocol.TunnelConfig {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return t.Config
|
||||
}
|
||||
|
||||
// ID returns the tunnel ID
|
||||
func (t *Tunnel) ID() string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return t.Config.ID
|
||||
}
|
||||
|
||||
func (t *Tunnel) restoreDynamicLeases(leases map[string]time.Time) {
|
||||
if len(leases) == 0 {
|
||||
return
|
||||
}
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
t.whitelist.RestoreDynamic(leases)
|
||||
}
|
||||
|
||||
// Name returns the tunnel display name
|
||||
func (t *Tunnel) Name() string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return t.Config.Name
|
||||
}
|
||||
|
||||
// AuthCode returns the web auth code
|
||||
func (t *Tunnel) AuthCode() string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return t.Config.AuthCode
|
||||
}
|
||||
|
||||
// AddDynamicWhitelist grants temporary IP access
|
||||
func (t *Tunnel) AddDynamicWhitelist(ip string, exp time.Time) {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
t.whitelist.AddDynamic(ip, exp)
|
||||
}
|
||||
|
||||
// GetDynamicWhitelist returns active granted IPs
|
||||
func (t *Tunnel) GetDynamicWhitelist() []string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return t.whitelist.GetDynamic()
|
||||
}
|
||||
|
||||
// RevokeDynamic removes temporary leases (all when ip is empty) and disconnects
|
||||
// active clients that are no longer permitted by the remaining rules.
|
||||
func (t *Tunnel) RevokeDynamic(ip string) (revoked, kicked int) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
revoked = t.whitelist.RemoveDynamic(ip)
|
||||
if revoked == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
for id, ac := range t.activeConns {
|
||||
if ok, _ := t.whitelist.IsAllowed(ac.ClientIP); !ok {
|
||||
_ = ac.Conn.Close()
|
||||
delete(t.activeConns, id)
|
||||
kicked++
|
||||
}
|
||||
}
|
||||
return revoked, kicked
|
||||
}
|
||||
|
||||
// EnforceAccess disconnects active clients that are no longer permitted — e.g. their
|
||||
// temporary lease expired or the whitelist changed. Returns the disconnected client addresses.
|
||||
func (t *Tunnel) EnforceAccess() []string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
var kicked []string
|
||||
for id, ac := range t.activeConns {
|
||||
if ok, _ := t.whitelist.IsAllowed(ac.ClientIP); !ok {
|
||||
_ = ac.Conn.Close()
|
||||
delete(t.activeConns, id)
|
||||
kicked = append(kicked, ac.ClientIP)
|
||||
}
|
||||
}
|
||||
return kicked
|
||||
}
|
||||
|
||||
func (t *Tunnel) dynamicLeases() map[string]time.Time {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return t.whitelist.DynamicLeases()
|
||||
}
|
||||
|
||||
func (t *Tunnel) acceptLoop() {
|
||||
var backoff time.Duration
|
||||
for {
|
||||
clientConn, err := t.listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-t.closed:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
// 临时错误(如文件句柄耗尽)退避后继续,不能让监听永久退出
|
||||
if backoff == 0 {
|
||||
backoff = 5 * time.Millisecond
|
||||
} else if backoff *= 2; backoff > time.Second {
|
||||
backoff = time.Second
|
||||
}
|
||||
log.Printf("[Tunnel %d] accept error: %v; retrying in %v", t.ListenPort(), err, backoff)
|
||||
select {
|
||||
case <-t.closed:
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
continue
|
||||
}
|
||||
backoff = 0
|
||||
|
||||
go t.handleClient(clientConn)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tunnel) handleClient(clientConn net.Conn) {
|
||||
remoteAddr := clientConn.RemoteAddr().String()
|
||||
|
||||
// 1. Check IP Whitelist immediately at the edge
|
||||
t.mu.RLock()
|
||||
allowed, reason := t.whitelist.IsAllowed(remoteAddr)
|
||||
tunnelID := t.Config.ID
|
||||
listenPort := t.Config.ListenPort
|
||||
t.mu.RUnlock()
|
||||
|
||||
if !allowed {
|
||||
t.blockedAttemps.Add(1)
|
||||
_ = clientConn.Close()
|
||||
|
||||
if t.onSecEvent != nil {
|
||||
t.onSecEvent(protocol.SecurityEvent{
|
||||
ID: uuid.New().String(),
|
||||
TunnelID: tunnelID,
|
||||
Port: listenPort,
|
||||
ClientIP: remoteAddr,
|
||||
Timestamp: time.Now(),
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
log.Printf("[Tunnel %d] BLOCKED connection from %s (%s)", listenPort, remoteAddr, reason)
|
||||
return
|
||||
}
|
||||
|
||||
t.totalConns.Add(1)
|
||||
connID := uuid.New().String()
|
||||
if t.onConnect != nil {
|
||||
host := remoteAddr
|
||||
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
host = h
|
||||
}
|
||||
t.onConnect(tunnelID, host)
|
||||
}
|
||||
|
||||
// 2. Open Yamux stream to Mac agent
|
||||
stream, err := t.session.OpenStream()
|
||||
if err != nil {
|
||||
log.Printf("[Tunnel %d] failed to open stream to agent: %v", listenPort, err)
|
||||
_ = clientConn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Send handshake line
|
||||
handshake := protocol.StreamHandshake{
|
||||
TunnelID: tunnelID,
|
||||
ConnID: connID,
|
||||
ClientIP: remoteAddr,
|
||||
}
|
||||
data, _ := json.Marshal(handshake)
|
||||
data = append(data, '\n')
|
||||
if _, err := stream.Write(data); err != nil {
|
||||
_ = stream.Close()
|
||||
_ = clientConn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Track active connection
|
||||
connMeter := traffic.NewMeter()
|
||||
wrappedClientConn := traffic.WrapConn(clientConn, connMeter)
|
||||
|
||||
ac := &ActiveClient{
|
||||
ID: connID,
|
||||
ClientIP: remoteAddr,
|
||||
ConnectedAt: time.Now(),
|
||||
Conn: clientConn,
|
||||
Meter: connMeter,
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
t.activeConns[connID] = ac
|
||||
t.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
t.mu.Lock()
|
||||
delete(t.activeConns, connID)
|
||||
t.mu.Unlock()
|
||||
_ = wrappedClientConn.Close()
|
||||
_ = stream.Close()
|
||||
}()
|
||||
|
||||
// 5. Bidirectional copy with optional QoS rate limiting
|
||||
done := make(chan struct{}, 2)
|
||||
go func() {
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
nr, er := wrappedClientConn.Read(buf)
|
||||
if nr > 0 {
|
||||
t.meter.AddIn(int64(nr))
|
||||
t.mu.RLock()
|
||||
lim := t.limiter
|
||||
t.mu.RUnlock()
|
||||
if lim != nil {
|
||||
lim.Allow(nr)
|
||||
}
|
||||
nw, ew := stream.Write(buf[0:nr])
|
||||
if ew != nil || nr != nw {
|
||||
break
|
||||
}
|
||||
}
|
||||
if er != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = stream.Close()
|
||||
_ = wrappedClientConn.Close()
|
||||
done <- struct{}{}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
nr, er := stream.Read(buf)
|
||||
if nr > 0 {
|
||||
t.meter.AddOut(int64(nr))
|
||||
t.mu.RLock()
|
||||
lim := t.limiter
|
||||
t.mu.RUnlock()
|
||||
if lim != nil {
|
||||
lim.Allow(nr)
|
||||
}
|
||||
nw, ew := wrappedClientConn.Write(buf[0:nr])
|
||||
if ew != nil || nr != nw {
|
||||
break
|
||||
}
|
||||
}
|
||||
if er != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = stream.Close()
|
||||
_ = wrappedClientConn.Close()
|
||||
done <- struct{}{}
|
||||
}()
|
||||
|
||||
<-done
|
||||
}
|
||||
|
||||
// KillConn closes a specific connection by ID
|
||||
func (t *Tunnel) KillConn(connID string) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if ac, ok := t.activeConns[connID]; ok {
|
||||
_ = ac.Conn.Close()
|
||||
delete(t.activeConns, connID)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Close shuts down the listener and disconnects all clients
|
||||
func (t *Tunnel) Close() {
|
||||
t.closeOnce.Do(func() {
|
||||
close(t.closed)
|
||||
_ = t.listener.Close()
|
||||
|
||||
t.mu.Lock()
|
||||
for _, ac := range t.activeConns {
|
||||
_ = ac.Conn.Close()
|
||||
}
|
||||
t.activeConns = make(map[string]*ActiveClient)
|
||||
t.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// Tick updates metrics
|
||||
func (t *Tunnel) Tick() {
|
||||
t.meter.Tick()
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
for _, ac := range t.activeConns {
|
||||
ac.Meter.Tick()
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics returns the current metrics snapshot
|
||||
func (t *Tunnel) Metrics() (protocol.TunnelMetrics, []protocol.ActiveConnInfo) {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
|
||||
bIn, bOut, rIn, rOut := t.meter.Stats()
|
||||
tm := protocol.TunnelMetrics{
|
||||
TunnelID: t.Config.ID,
|
||||
ListenPort: t.Config.ListenPort,
|
||||
ActiveConns: len(t.activeConns),
|
||||
TotalConns: t.totalConns.Load(),
|
||||
BytesIn: bIn,
|
||||
BytesOut: bOut,
|
||||
RateIn: rIn,
|
||||
RateOut: rOut,
|
||||
BlockedAttemps: t.blockedAttemps.Load(),
|
||||
Status: "online",
|
||||
DynamicWhitelist: t.whitelist.GetDynamic(),
|
||||
}
|
||||
for ip, exp := range t.whitelist.DynamicLeases() {
|
||||
tm.DynamicLeases = append(tm.DynamicLeases, protocol.DynamicLease{IP: ip, ExpiresAt: exp})
|
||||
}
|
||||
sort.Slice(tm.DynamicLeases, func(i, j int) bool {
|
||||
return tm.DynamicLeases[i].ExpiresAt.Before(tm.DynamicLeases[j].ExpiresAt)
|
||||
})
|
||||
|
||||
conns := make([]protocol.ActiveConnInfo, 0, len(t.activeConns))
|
||||
for _, ac := range t.activeConns {
|
||||
cbIn, cbOut, crIn, crOut := ac.Meter.Stats()
|
||||
conns = append(conns, protocol.ActiveConnInfo{
|
||||
ConnID: ac.ID,
|
||||
TunnelID: t.Config.ID,
|
||||
ClientIP: ac.ClientIP,
|
||||
ConnectedAt: ac.ConnectedAt,
|
||||
BytesIn: cbIn,
|
||||
BytesOut: cbOut,
|
||||
RateIn: crIn,
|
||||
RateOut: crOut,
|
||||
})
|
||||
}
|
||||
return tm, conns
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/DeepFal/PortRelay/core/protocol"
|
||||
)
|
||||
|
||||
const maxNameRunes = 32
|
||||
|
||||
func (m *Manager) handleHTTPRequest(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
switch {
|
||||
case path == "/health":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"ok","server":"PortRelay"}`))
|
||||
case strings.HasPrefix(path, "/i/") && r.Method != http.MethodPost:
|
||||
m.serveInviteForm(w, r, strings.TrimPrefix(path, "/i/"))
|
||||
case path == "/auth" && r.Method == http.MethodPost:
|
||||
m.serveInviteSubmit(w, r)
|
||||
case path == "/auth":
|
||||
m.serveInviteForm(w, r, r.URL.Query().Get("code"))
|
||||
case path == "/auth/status":
|
||||
m.serveRequestStatus(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func clientIPOf(r *http.Request) string {
|
||||
ip := r.RemoteAddr
|
||||
if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
ip = h
|
||||
}
|
||||
if parsed := net.ParseIP(ip); parsed != nil {
|
||||
return parsed.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// accessHost returns the host LAN users should connect to: the configured public host,
|
||||
// otherwise the host the visitor used to open the link (it reached this server).
|
||||
func (m *Manager) accessHost(r *http.Request) string {
|
||||
if m.PublicHost != "" {
|
||||
return m.PublicHost
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(r.Host); err == nil {
|
||||
return h
|
||||
}
|
||||
return r.Host
|
||||
}
|
||||
|
||||
// inviteTarget resolves an invite code to its (currently registered) tunnel
|
||||
func (m *Manager) inviteTarget(code string) (*Tunnel, protocol.Invite, string) {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return nil, protocol.Invite{}, "缺少邀请码"
|
||||
}
|
||||
tunnelID, inv, ok := m.state.findInvite(code)
|
||||
if !ok {
|
||||
return nil, protocol.Invite{}, "链接无效或已过期,请向管理员索取新的邀请链接"
|
||||
}
|
||||
t := m.tunnel(tunnelID)
|
||||
if t == nil {
|
||||
return nil, protocol.Invite{}, "对应通道当前未开启,请联系管理员"
|
||||
}
|
||||
return t, inv, ""
|
||||
}
|
||||
|
||||
func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code string) {
|
||||
ip := clientIPOf(r)
|
||||
t, inv, problem := m.inviteTarget(code)
|
||||
if problem == "" && ip == "" {
|
||||
problem = "无法识别客户端 IP"
|
||||
}
|
||||
if problem == "" {
|
||||
if err := m.state.checkInvite(t.ID(), inv.Code, ip); err != nil {
|
||||
problem = err.Error()
|
||||
}
|
||||
}
|
||||
if problem != "" {
|
||||
m.writePage(w, r, http.StatusNotFound, pageError, "无法使用此链接", problemHTML(problem))
|
||||
return
|
||||
}
|
||||
|
||||
cfg := t.Snapshot()
|
||||
action := "确认并获取访问权限"
|
||||
approvalTip := ""
|
||||
if inv.RequireApproval {
|
||||
action = "提交访问申请"
|
||||
approvalTip = `<div class="tip">此邀请需要管理员审批,提交后请保持此页面打开,批准后会自动显示结果。</div>`
|
||||
}
|
||||
body := fmt.Sprintf(`
|
||||
%s
|
||||
<form method="post" action="/auth" class="form">
|
||||
<input type="hidden" name="code" value="%s">
|
||||
<label for="name">您的姓名</label>
|
||||
<input id="name" name="name" required maxlength="%d" placeholder="例如:张三" autofocus>
|
||||
<button type="submit">%s</button>
|
||||
</form>
|
||||
%s`,
|
||||
m.detailRows(r, cfg, ip, durationText(inv.LeaseDuration)),
|
||||
html.EscapeString(inv.Code), maxNameRunes, action, approvalTip)
|
||||
m.writePage(w, r, http.StatusOK, pageForm, "申请访问 "+cfg.Name, body)
|
||||
}
|
||||
|
||||
func sanitizeName(s string) string {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
return truncateRunes(s, maxNameRunes)
|
||||
}
|
||||
|
||||
func (m *Manager) serveInviteSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = io.NopCloser(io.LimitReader(r.Body, 8<<10))
|
||||
if err := r.ParseForm(); err != nil {
|
||||
m.writePage(w, r, http.StatusBadRequest, pageError, "请求无效", problemHTML("表单内容无法解析"))
|
||||
return
|
||||
}
|
||||
ip := clientIPOf(r)
|
||||
name := sanitizeName(r.PostForm.Get("name"))
|
||||
t, inv, problem := m.inviteTarget(r.PostForm.Get("code"))
|
||||
switch {
|
||||
case problem != "":
|
||||
case ip == "":
|
||||
problem = "无法识别客户端 IP"
|
||||
case name == "":
|
||||
problem = "请填写您的姓名"
|
||||
}
|
||||
if problem != "" {
|
||||
m.writePage(w, r, http.StatusBadRequest, pageError, "无法完成授权", problemHTML(problem))
|
||||
return
|
||||
}
|
||||
|
||||
cfg := t.Snapshot()
|
||||
if inv.RequireApproval {
|
||||
req, err := m.state.addRequest(cfg.ID, inv.Code, name, ip)
|
||||
if err != nil {
|
||||
m.writePage(w, r, http.StatusConflict, pageError, "无法提交申请", problemHTML(err.Error()))
|
||||
return
|
||||
}
|
||||
log.Printf("[WebAuth] Access request from %s (%s) on tunnel %s awaiting approval", name, ip, cfg.Name)
|
||||
http.Redirect(w, r, "/auth/status?id="+url.QueryEscape(req.ID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
lease, err := m.state.useInviteAndGrant(cfg.ID, inv.Code, name, ip, cfg.ExpiresAt)
|
||||
if err != nil {
|
||||
m.writePage(w, r, http.StatusConflict, pageError, "无法完成授权", problemHTML(err.Error()))
|
||||
return
|
||||
}
|
||||
t.AddDynamicWhitelist(ip, lease.ExpiresAt)
|
||||
log.Printf("[WebAuth] Granted %s (%s) access to tunnel %s (port %d): %s",
|
||||
name, ip, cfg.Name, cfg.ListenPort, leaseValidity(lease))
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "授权成功", m.successBody(r, cfg, ip, lease))
|
||||
}
|
||||
|
||||
func (m *Manager) serveRequestStatus(w http.ResponseWriter, r *http.Request) {
|
||||
req, ok := m.state.request(r.URL.Query().Get("id"))
|
||||
if !ok {
|
||||
m.writePage(w, r, http.StatusNotFound, pageError, "申请已失效", problemHTML("申请不存在或已过期,请重新打开邀请链接提交"))
|
||||
return
|
||||
}
|
||||
t := m.tunnel(req.TunnelID)
|
||||
switch {
|
||||
case req.Status == "rejected":
|
||||
m.writePage(w, r, http.StatusOK, pageError, "申请未通过", problemHTML("管理员拒绝了您的访问申请"))
|
||||
case t == nil:
|
||||
m.writePage(w, r, http.StatusOK, pageError, "通道未开启", problemHTML("对应通道当前未开启,请联系管理员"))
|
||||
case req.Status == "approved":
|
||||
cfg := t.Snapshot()
|
||||
lease, _ := m.state.lease(cfg.ID, req.IP)
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "申请已批准", m.successBody(r, cfg, req.IP, lease))
|
||||
default:
|
||||
cfg := t.Snapshot()
|
||||
duration := ""
|
||||
if _, inv, ok := m.state.findInvite(req.Invite); ok {
|
||||
duration = inv.LeaseDuration
|
||||
}
|
||||
body := m.detailRows(r, cfg, req.IP, durationText(duration)) +
|
||||
fmt.Sprintf(`<div class="tip">%s,您的申请已提交,正在等待管理员批准。此页面会自动刷新。</div>`, html.EscapeString(req.Name))
|
||||
w.Header().Set("Refresh", "5")
|
||||
m.writePage(w, r, http.StatusOK, pagePending, "等待管理员批准", body)
|
||||
}
|
||||
}
|
||||
|
||||
// durationText describes how long access lasts once granted
|
||||
func durationText(d string) string {
|
||||
if d == protocol.LeaseDurationTunnel {
|
||||
return protocol.DescribeLeaseDuration(d)
|
||||
}
|
||||
return protocol.DescribeLeaseDuration(d) + "(从首次连接开始计算)"
|
||||
}
|
||||
|
||||
// leaseValidity describes a granted lease: not yet started, or running until its expiry
|
||||
func leaseValidity(l leaseInfo) string {
|
||||
if l.Deferred {
|
||||
return fmt.Sprintf("首次连接后 %s,请在 %s 前连接", protocol.DescribeLeaseDuration(l.Duration), l.ExpiresAt.Format("01-02 15:04"))
|
||||
}
|
||||
return "至 " + l.ExpiresAt.Format("01-02 15:04")
|
||||
}
|
||||
|
||||
func (m *Manager) successBody(r *http.Request, cfg protocol.TunnelConfig, ip string, l leaseInfo) string {
|
||||
return m.detailRows(r, cfg, ip, leaseValidity(l)) +
|
||||
`<div class="tip">您的设备已获得访问权限,现在可以直接用客户端连接上面的访问地址,计时从首次连接开始。到期后如需继续使用,请向管理员索取新的邀请链接。</div>`
|
||||
}
|
||||
|
||||
// detailRows renders the tunnel info table
|
||||
func (m *Manager) detailRows(r *http.Request, cfg protocol.TunnelConfig, ip string, validity string) string {
|
||||
validity = html.EscapeString(validity)
|
||||
return fmt.Sprintf(`
|
||||
<div class="row"><span class="label">目标通道</span><span class="value">%s</span></div>
|
||||
<div class="row"><span class="label">访问地址</span><span class="value mono">%s:%d</span></div>
|
||||
<div class="row"><span class="label">您的设备 IP</span><span class="value mono">%s</span></div>
|
||||
<div class="row"><span class="label">访问有效期</span><span class="value highlight">%s</span></div>`,
|
||||
html.EscapeString(cfg.Name), html.EscapeString(m.accessHost(r)), cfg.ListenPort, html.EscapeString(ip), validity)
|
||||
}
|
||||
|
||||
func problemHTML(msg string) string {
|
||||
return fmt.Sprintf(`<div class="tip error">%s</div>`, html.EscapeString(msg))
|
||||
}
|
||||
|
||||
type pageKind int
|
||||
|
||||
const (
|
||||
pageSuccess pageKind = iota
|
||||
pageError
|
||||
pagePending
|
||||
pageForm
|
||||
)
|
||||
|
||||
func (m *Manager) writePage(w http.ResponseWriter, r *http.Request, status int, kind pageKind, title, body string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(renderPage(kind, title, body)))
|
||||
}
|
||||
|
||||
func renderPage(kind pageKind, title, body string) string {
|
||||
color := "#34C759"
|
||||
icon := `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#34C759" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>`
|
||||
switch kind {
|
||||
case pageError:
|
||||
color = "#FF3B30"
|
||||
icon = `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#FF3B30" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>`
|
||||
case pagePending:
|
||||
color = "#FF9500"
|
||||
icon = `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#FF9500" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>`
|
||||
case pageForm:
|
||||
color = "#007AFF"
|
||||
icon = `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#007AFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>`
|
||||
}
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>%s - PortRelay</title>
|
||||
<style>
|
||||
:root { --bg:#F5F5F7; --card:#FFFFFF; --text:#1D1D1F; --sub:#86868B; --border:#D2D2D7; --code-bg:#E8E8ED; --accent:%s; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --bg:#000000; --card:#1C1C1E; --text:#F5F5F7; --sub:#8E8E93; --border:#38383A; --code-bg:#2C2C2E; }
|
||||
}
|
||||
body { margin:0; padding:24px 16px; background:var(--bg); color:var(--text);
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
display:flex; justify-content:center; align-items:center; min-height:90vh; }
|
||||
.card { background:var(--card); border:1px solid var(--border); border-radius:16px; max-width:440px; width:100%%;
|
||||
padding:32px 24px; box-shadow:0 4px 24px rgba(0,0,0,0.06); box-sizing:border-box; }
|
||||
.header { text-align:center; margin-bottom:24px; }
|
||||
.title { font-size:20px; font-weight:700; margin-top:12px; margin-bottom:4px; }
|
||||
.sub { font-size:13px; color:var(--sub); }
|
||||
.row { display:flex; justify-content:space-between; align-items:center; gap:12px; padding:10px 0; border-bottom:1px solid var(--border); font-size:13px; }
|
||||
.label { color:var(--sub); white-space:nowrap; }
|
||||
.value { font-weight:600; text-align:right; word-break:break-all; }
|
||||
.mono { font-family:ui-monospace,Menlo,Monaco,Consolas,monospace; background:var(--code-bg); padding:2px 6px; border-radius:4px; }
|
||||
.highlight { color:var(--accent); }
|
||||
.tip { margin-top:20px; padding:12px 14px; background:var(--code-bg); border-radius:8px; font-size:12px; line-height:1.5; color:var(--sub); }
|
||||
.tip.error { color:#FF3B30; background:rgba(255,59,48,0.1); font-weight:600; text-align:center; }
|
||||
.form { margin-top:20px; display:flex; flex-direction:column; gap:8px; }
|
||||
.form label { font-size:13px; color:var(--sub); }
|
||||
.form input { font-size:16px; padding:10px 12px; border-radius:8px; border:1px solid var(--border); background:var(--bg); color:var(--text); }
|
||||
.form button { margin-top:8px; font-size:15px; font-weight:600; padding:11px; border:0; border-radius:8px; background:var(--accent); color:#fff; cursor:pointer; }
|
||||
.footer { text-align:center; margin-top:24px; font-size:11px; color:var(--sub); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
%s
|
||||
<div class="title">%s</div>
|
||||
<div class="sub">PortRelay 端口访问授权</div>
|
||||
</div>
|
||||
%s
|
||||
<div class="footer">PortRelay 安全防护 · 临时访问通行证</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, html.EscapeString(title), color, icon, html.EscapeString(title), body)
|
||||
}
|
||||
Reference in new issue
Block a user