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,193 @@
|
||||
package acl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Whitelist checks if an IP is allowed based on rules and dynamic leases
|
||||
type Whitelist struct {
|
||||
rules []string
|
||||
allowAll bool
|
||||
ips map[string]bool
|
||||
subnets []*net.IPNet
|
||||
dynamic map[string]time.Time // dynamic IP -> expiration
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewWhitelist parses a list of rules
|
||||
func NewWhitelist(rules []string) *Whitelist {
|
||||
w := &Whitelist{
|
||||
rules: rules,
|
||||
ips: make(map[string]bool),
|
||||
subnets: make([]*net.IPNet, 0),
|
||||
dynamic: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
for _, r := range rules {
|
||||
r = strings.TrimSpace(r)
|
||||
if r == "" {
|
||||
continue
|
||||
}
|
||||
if r == "*" || r == "0.0.0.0/0" || r == "::/0" {
|
||||
w.allowAll = true
|
||||
continue
|
||||
}
|
||||
if strings.Contains(r, "/") {
|
||||
_, ipNet, err := net.ParseCIDR(r)
|
||||
if err == nil {
|
||||
w.subnets = append(w.subnets, ipNet)
|
||||
}
|
||||
} else {
|
||||
ip := net.ParseIP(r)
|
||||
if ip != nil {
|
||||
w.ips[ip.String()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// ValidateRule reports whether a whitelist rule is "*", a valid IP or a valid CIDR
|
||||
func ValidateRule(r string) error {
|
||||
r = strings.TrimSpace(r)
|
||||
switch {
|
||||
case r == "":
|
||||
return fmt.Errorf("白名单规则不能为空")
|
||||
case r == "*":
|
||||
return nil
|
||||
case strings.Contains(r, "/"):
|
||||
if _, _, err := net.ParseCIDR(r); err != nil {
|
||||
return fmt.Errorf("无效的网段 %q", r)
|
||||
}
|
||||
default:
|
||||
if net.ParseIP(r) == nil {
|
||||
return fmt.Errorf("无效的 IP 地址 %q", r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DynamicLeases returns a copy of unexpired dynamic leases
|
||||
func (w *Whitelist) DynamicLeases() map[string]time.Time {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
now := time.Now()
|
||||
out := make(map[string]time.Time, len(w.dynamic))
|
||||
for ip, exp := range w.dynamic {
|
||||
if now.Before(exp) {
|
||||
out[ip] = exp
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RestoreDynamic merges previously granted leases (e.g. after a config update)
|
||||
func (w *Whitelist) RestoreDynamic(leases map[string]time.Time) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
for ip, exp := range leases {
|
||||
if cur, ok := w.dynamic[ip]; !ok || exp.After(cur) {
|
||||
w.dynamic[ip] = exp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveDynamic revokes the lease for ip (all leases when ip is empty) and returns how many were removed
|
||||
func (w *Whitelist) RemoveDynamic(ip string) int {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if ip == "" {
|
||||
n := len(w.dynamic)
|
||||
w.dynamic = make(map[string]time.Time)
|
||||
return n
|
||||
}
|
||||
if parsed := net.ParseIP(strings.TrimSpace(ip)); parsed != nil {
|
||||
if _, ok := w.dynamic[parsed.String()]; ok {
|
||||
delete(w.dynamic, parsed.String())
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// AddDynamic adds an IP with an expiration timestamp
|
||||
func (w *Whitelist) AddDynamic(ipStr string, expireAt time.Time) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
host := ipStr
|
||||
if h, _, err := net.SplitHostPort(ipStr); err == nil {
|
||||
host = h
|
||||
}
|
||||
parsed := net.ParseIP(host)
|
||||
if parsed != nil {
|
||||
w.dynamic[parsed.String()] = expireAt
|
||||
}
|
||||
}
|
||||
|
||||
// GetDynamic returns active unexpired dynamic IPs
|
||||
func (w *Whitelist) GetDynamic() []string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
now := time.Now()
|
||||
var list []string
|
||||
for ip, exp := range w.dynamic {
|
||||
if now.Before(exp) {
|
||||
list = append(list, ip)
|
||||
} else {
|
||||
delete(w.dynamic, ip)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// IsAllowed checks if remoteAddr (either "IP" or "IP:Port") is permitted
|
||||
func (w *Whitelist) IsAllowed(remoteAddr string) (bool, string) {
|
||||
if w.allowAll {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
host := remoteAddr
|
||||
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
host = h
|
||||
}
|
||||
|
||||
parsedIP := net.ParseIP(host)
|
||||
if parsedIP == nil {
|
||||
return false, "invalid client IP"
|
||||
}
|
||||
|
||||
ipStr := parsedIP.String()
|
||||
|
||||
// Canonical IP string check
|
||||
if w.ips[ipStr] {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// Dynamic leases check
|
||||
w.mu.RLock()
|
||||
if exp, ok := w.dynamic[ipStr]; ok {
|
||||
if time.Now().Before(exp) {
|
||||
w.mu.RUnlock()
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
w.mu.RUnlock()
|
||||
|
||||
// CIDR subnets check
|
||||
for _, subnet := range w.subnets {
|
||||
if subnet.Contains(parsedIP) {
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
|
||||
return false, "IP not in whitelist"
|
||||
}
|
||||
|
||||
// Rules returns the raw rules
|
||||
func (w *Whitelist) Rules() []string {
|
||||
return w.rules
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package acl
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWhitelist(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rules []string
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "allow all wildcard",
|
||||
rules: []string{"*"},
|
||||
ip: "192.168.1.50:12345",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "single ip match",
|
||||
rules: []string{"192.168.1.50"},
|
||||
ip: "192.168.1.50:12345",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "single ip mismatch",
|
||||
rules: []string{"192.168.1.50"},
|
||||
ip: "192.168.1.51:12345",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "cidr match",
|
||||
rules: []string{"192.168.1.0/24"},
|
||||
ip: "192.168.1.77:45678",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "cidr mismatch",
|
||||
rules: []string{"192.168.1.0/24"},
|
||||
ip: "192.168.2.190:45678",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "multiple rules mixed",
|
||||
rules: []string{"10.0.0.1", "192.168.1.0/24"},
|
||||
ip: "10.0.0.1:80",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "empty rules reject all",
|
||||
rules: []string{},
|
||||
ip: "127.0.0.1:80",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
wl := NewWhitelist(tt.rules)
|
||||
allowed, _ := wl.IsAllowed(tt.ip)
|
||||
if allowed != tt.expected {
|
||||
t.Errorf("expected %v for IP %s with rules %v, got %v", tt.expected, tt.ip, tt.rules, allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRule(t *testing.T) {
|
||||
for _, ok := range []string{"*", "192.168.1.100", "192.168.1.0/24", "::1", " 10.0.0.1 "} {
|
||||
if err := ValidateRule(ok); err != nil {
|
||||
t.Errorf("ValidateRule(%q) = %v, want nil", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "192.168.1", "10.0.0.0/33", "abc", "192.168.1.*"} {
|
||||
if err := ValidateRule(bad); err == nil {
|
||||
t.Errorf("ValidateRule(%q) = nil, want error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicLeasesRoundTrip(t *testing.T) {
|
||||
w1 := NewWhitelist([]string{"10.0.0.1"})
|
||||
w1.AddDynamic("192.168.1.5", time.Now().Add(time.Hour))
|
||||
w1.AddDynamic("192.168.1.6", time.Now().Add(-time.Second))
|
||||
|
||||
w2 := NewWhitelist([]string{"10.0.0.2"})
|
||||
w2.RestoreDynamic(w1.DynamicLeases())
|
||||
if ok, _ := w2.IsAllowed("192.168.1.5:1234"); !ok {
|
||||
t.Error("restored lease not honoured")
|
||||
}
|
||||
if ok, _ := w2.IsAllowed("192.168.1.6"); ok {
|
||||
t.Error("expired lease should not be restored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveDynamic(t *testing.T) {
|
||||
w := NewWhitelist(nil)
|
||||
w.AddDynamic("10.0.0.1", time.Now().Add(time.Hour))
|
||||
w.AddDynamic("10.0.0.2", time.Now().Add(time.Hour))
|
||||
if n := w.RemoveDynamic("10.0.0.1"); n != 1 {
|
||||
t.Fatalf("removed %d, want 1", n)
|
||||
}
|
||||
if ok, _ := w.IsAllowed("10.0.0.1"); ok {
|
||||
t.Error("revoked IP still allowed")
|
||||
}
|
||||
if n := w.RemoveDynamic(""); n != 1 {
|
||||
t.Fatalf("remove all removed %d, want 1", n)
|
||||
}
|
||||
if ok, _ := w.IsAllowed("10.0.0.2"); ok {
|
||||
t.Error("IP still allowed after revoke-all")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package protocol
|
||||
|
||||
import "time"
|
||||
|
||||
// Message types
|
||||
const (
|
||||
TypeAuth = "auth"
|
||||
TypeAuthResp = "auth_resp"
|
||||
TypeRegisterTunnel = "register_tunnel"
|
||||
TypeRegisterResp = "register_resp"
|
||||
TypeUnregister = "unregister_tunnel"
|
||||
TypeUnregisterResp = "unregister_resp"
|
||||
TypeUpdateTunnel = "update_tunnel"
|
||||
TypeKillConn = "kill_connection"
|
||||
TypeRevokeLease = "revoke_lease"
|
||||
TypeCreateInvite = "create_invite"
|
||||
TypeInviteResp = "invite_resp"
|
||||
TypeRevokeInvite = "revoke_invite"
|
||||
TypeDecideRequest = "decide_request"
|
||||
TypeMetrics = "metrics_update"
|
||||
TypePing = "ping"
|
||||
TypePong = "pong"
|
||||
)
|
||||
|
||||
// BaseMessage represents any control message
|
||||
type BaseMessage struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// AuthMessage sent by agent to authenticate
|
||||
type AuthMessage struct {
|
||||
Type string `json:"type"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// AuthRespMessage returned by server
|
||||
type AuthRespMessage struct {
|
||||
Type string `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TunnelConfig is the definition of a tunnel
|
||||
type TunnelConfig struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Target string `json:"target"` // Target host:port (accessed by Mac agent, e.g. 10.0.1.16:3306)
|
||||
ListenPort int `json:"listen_port"`
|
||||
Whitelist []string `json:"whitelist"` // e.g. ["192.168.1.0/24", "192.168.1.100", "*"]
|
||||
Enabled bool `json:"enabled"`
|
||||
Owner string `json:"owner,omitempty"` // 责任人
|
||||
DurationMinutes int `json:"duration_minutes,omitempty"` // 最长开启时长,到时无论是否在用都关闭 (0: 不限制)
|
||||
// IdleCloseMinutes 闲置多久自动关闭(无在线连接、无有效授权、无待审批、无可用邀请);nil 表示默认 30 分钟,0 表示不自动关闭
|
||||
IdleCloseMinutes *int `json:"idle_close_minutes,omitempty"`
|
||||
// 以下由程序维护
|
||||
CloseReason string `json:"close_reason,omitempty"` // 上次关闭原因
|
||||
FirstConnectedAt *time.Time `json:"first_connected_at,omitempty"` // 本次开启后首次有连接的时间
|
||||
ConnectedSec int64 `json:"connected_sec,omitempty"` // 本次开启后有连接在线的累计秒数
|
||||
LastConnectedSec int64 `json:"last_connected_sec,omitempty"` // 上次开启期间有连接在线的累计秒数
|
||||
StartedAt *time.Time `json:"started_at,omitempty"` // 本次开启时间
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"` // 到期自动结束时间
|
||||
HistoricalBytesIn int64 `json:"historical_bytes_in,omitempty"` // 历史累计入流量
|
||||
HistoricalBytesOut int64 `json:"historical_bytes_out,omitempty"` // 历史累计出流量
|
||||
LastDurationSec int64 `json:"last_duration_sec,omitempty"` // 上次开启累计运行秒数
|
||||
MaxBandwidthMB float64 `json:"max_bandwidth_mb,omitempty"` // 最大带宽限速 (MB/s, 0 为不限制)
|
||||
AuthCode string `json:"auth_code,omitempty"` // 网页放通授权识别码
|
||||
DynamicWhitelist []string `json:"dynamic_whitelist,omitempty"` // 动态放通中的客户端 IP 列表
|
||||
Notes string `json:"notes,omitempty"`
|
||||
LeaseDuration string `json:"lease_duration,omitempty"` // 已废弃:放通时长改为在每个邀请上设置,保留字段仅为兼容旧配置
|
||||
RequireApproval bool `json:"require_approval,omitempty"` // 已废弃:是否审批改为在每个邀请上设置,保留字段仅为兼容旧配置
|
||||
}
|
||||
|
||||
// RegisterTunnelMessage registers a tunnel on server
|
||||
type RegisterTunnelMessage struct {
|
||||
Type string `json:"type"`
|
||||
Tunnel TunnelConfig `json:"tunnel"`
|
||||
}
|
||||
|
||||
// RegisterRespMessage response to register
|
||||
type RegisterRespMessage struct {
|
||||
Type string `json:"type"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
// UnregisterMessage removes a tunnel
|
||||
type UnregisterMessage struct {
|
||||
Type string `json:"type"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
}
|
||||
|
||||
// UnregisterRespMessage response to unregister
|
||||
type UnregisterRespMessage struct {
|
||||
Type string `json:"type"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// UpdateTunnelMessage updates tunnel configuration (e.g. whitelist or name)
|
||||
type UpdateTunnelMessage struct {
|
||||
Type string `json:"type"`
|
||||
Tunnel TunnelConfig `json:"tunnel"`
|
||||
}
|
||||
|
||||
// KillConnMessage requests server to terminate a specific client connection
|
||||
type KillConnMessage struct {
|
||||
Type string `json:"type"`
|
||||
ConnID string `json:"conn_id"`
|
||||
}
|
||||
|
||||
// RevokeLeaseMessage removes a temporary (web-auth) whitelist lease; empty IP revokes all leases of the tunnel
|
||||
type RevokeLeaseMessage struct {
|
||||
Type string `json:"type"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
}
|
||||
|
||||
// Lease durations (Invite.LeaseDuration):通过邀请获得授权后可访问多久
|
||||
const (
|
||||
LeaseDuration1h = "1h"
|
||||
LeaseDuration2h = "2h"
|
||||
LeaseDuration8h = "8h"
|
||||
LeaseDurationToday = "today" // 当天 23:59:59 前有效
|
||||
LeaseDurationTunnel = "tunnel" // 通道开启期间有效
|
||||
)
|
||||
|
||||
// ValidLeaseDuration reports whether d is a supported lease duration ("" means default 2h)
|
||||
func ValidLeaseDuration(d string) bool {
|
||||
switch d {
|
||||
case "", LeaseDuration1h, LeaseDuration2h, LeaseDuration8h, LeaseDurationToday, LeaseDurationTunnel:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DescribeLeaseDuration is the human-readable lease duration
|
||||
func DescribeLeaseDuration(d string) string {
|
||||
switch d {
|
||||
case LeaseDuration1h:
|
||||
return "1 小时"
|
||||
case LeaseDuration8h:
|
||||
return "8 小时"
|
||||
case LeaseDurationToday:
|
||||
return "当天有效"
|
||||
case LeaseDurationTunnel:
|
||||
return "通道开启期间有效"
|
||||
default:
|
||||
return "2 小时"
|
||||
}
|
||||
}
|
||||
|
||||
// DynamicLease is a temporary whitelist entry granted through an invite link
|
||||
type DynamicLease struct {
|
||||
IP string `json:"ip"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Name string `json:"name,omitempty"` // 申请人填写的名字
|
||||
Invite string `json:"invite,omitempty"` // 来源邀请码
|
||||
// Duration 授权时长;计时从该设备首次连接开始
|
||||
Duration string `json:"duration,omitempty"`
|
||||
// AwaitingConnect 为 true 表示已授权但尚未首次连接:计时未开始,ExpiresAt 是必须首次连接的截止时间
|
||||
AwaitingConnect bool `json:"awaiting_connect,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"` // 首次连接(开始计时)的时间
|
||||
}
|
||||
|
||||
// InviteUse records one device that used an invite
|
||||
type InviteUse struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// Invite is a shareable link that lets a limited number of devices request access
|
||||
type Invite struct {
|
||||
Code string `json:"code"`
|
||||
Note string `json:"note,omitempty"` // 管理员备注(发给谁)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"` // 链接可被打开的截止时间
|
||||
MaxUses int `json:"max_uses"` // 0 表示不限设备数
|
||||
// LeaseDuration 通过该邀请获得授权后可访问多久(每个邀请单独设置)
|
||||
LeaseDuration string `json:"lease_duration,omitempty"`
|
||||
// RequireApproval 通过该邀请提交的申请需管理员批准后才放通
|
||||
RequireApproval bool `json:"require_approval,omitempty"`
|
||||
Uses []InviteUse `json:"uses,omitempty"`
|
||||
}
|
||||
|
||||
// AccessRequest is an access application on a tunnel that requires approval
|
||||
type AccessRequest struct {
|
||||
ID string `json:"id"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
Invite string `json:"invite,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Status string `json:"status"` // pending / approved / rejected
|
||||
}
|
||||
|
||||
// CreateInviteMessage asks the server to create an invite link
|
||||
type CreateInviteMessage struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"request_id"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
Note string `json:"note,omitempty"`
|
||||
ValidMinutes int `json:"valid_minutes"`
|
||||
MaxUses int `json:"max_uses"`
|
||||
// LeaseDuration 授权后可访问时长,见 LeaseDuration* 常量
|
||||
LeaseDuration string `json:"lease_duration,omitempty"`
|
||||
// RequireApproval 打开链接后需管理员批准才放通
|
||||
RequireApproval bool `json:"require_approval,omitempty"`
|
||||
}
|
||||
|
||||
// InviteRespMessage answers CreateInviteMessage
|
||||
type InviteRespMessage struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"request_id"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Invite Invite `json:"invite"`
|
||||
}
|
||||
|
||||
// RevokeInviteMessage invalidates an invite and the leases it granted
|
||||
type RevokeInviteMessage struct {
|
||||
Type string `json:"type"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// DecideRequestMessage approves or rejects a pending access request
|
||||
type DecideRequestMessage struct {
|
||||
Type string `json:"type"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
RequestID string `json:"request_id"`
|
||||
Approve bool `json:"approve"`
|
||||
}
|
||||
|
||||
// ActiveConnInfo details about currently connected client
|
||||
type ActiveConnInfo struct {
|
||||
ConnID string `json:"conn_id"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
ConnectedAt time.Time `json:"connected_at"`
|
||||
BytesIn int64 `json:"bytes_in"` // Upload from client to target
|
||||
BytesOut int64 `json:"bytes_out"` // Download from target to client
|
||||
RateIn int64 `json:"rate_in"` // Current bytes/sec
|
||||
RateOut int64 `json:"rate_out"` // Current bytes/sec
|
||||
}
|
||||
|
||||
// ClientHistoryRecord historical record of a client connection
|
||||
type ClientHistoryRecord struct {
|
||||
ID string `json:"id"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
TunnelName string `json:"tunnel_name"`
|
||||
Port int `json:"port"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
ConnectedAt time.Time `json:"connected_at"`
|
||||
DisconnectedAt time.Time `json:"disconnected_at"`
|
||||
DurationSec int64 `json:"duration_sec"`
|
||||
BytesIn int64 `json:"bytes_in"`
|
||||
BytesOut int64 `json:"bytes_out"`
|
||||
}
|
||||
|
||||
// TunnelMetrics summary statistics for a tunnel
|
||||
type TunnelMetrics struct {
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
ListenPort int `json:"listen_port"`
|
||||
ActiveConns int `json:"active_conns"`
|
||||
TotalConns int64 `json:"total_conns"`
|
||||
BytesIn int64 `json:"bytes_in"` // 本次会话入流量
|
||||
BytesOut int64 `json:"bytes_out"` // 本次会话出流量
|
||||
RateIn int64 `json:"rate_in"`
|
||||
RateOut int64 `json:"rate_out"`
|
||||
BlockedAttemps int64 `json:"blocked_attempts"`
|
||||
Status string `json:"status"` // "online", "error", "disabled"
|
||||
Error string `json:"error,omitempty"`
|
||||
DynamicWhitelist []string `json:"dynamic_whitelist,omitempty"`
|
||||
DynamicLeases []DynamicLease `json:"dynamic_leases,omitempty"`
|
||||
Invites []Invite `json:"invites,omitempty"`
|
||||
Pending []AccessRequest `json:"pending,omitempty"`
|
||||
}
|
||||
|
||||
// SecurityEvent records blocked or flagged connection attempts
|
||||
type SecurityEvent struct {
|
||||
ID string `json:"id"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
Port int `json:"port"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// MetricsMessage pushed by server to agent periodically (e.g. every 1s)
|
||||
type MetricsMessage struct {
|
||||
Type string `json:"type"`
|
||||
Tunnels []TunnelMetrics `json:"tunnels"`
|
||||
ActiveConns []ActiveConnInfo `json:"active_conns"`
|
||||
SecurityEvents []SecurityEvent `json:"security_events,omitempty"`
|
||||
}
|
||||
|
||||
// StreamHandshake sent as first line when a data stream is opened
|
||||
type StreamHandshake struct {
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
ConnID string `json:"conn_id"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package traffic
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Meter tracks bandwidth and total bytes
|
||||
type Meter struct {
|
||||
bytesIn atomic.Int64 // Read from client (upload)
|
||||
bytesOut atomic.Int64 // Written to client (download)
|
||||
lastBytesIn int64
|
||||
lastBytesOut int64
|
||||
rateIn atomic.Int64 // Bytes per second
|
||||
rateOut atomic.Int64 // Bytes per second
|
||||
lastTick time.Time
|
||||
}
|
||||
|
||||
// NewMeter creates a new meter
|
||||
func NewMeter() *Meter {
|
||||
return &Meter{
|
||||
lastTick: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// AddIn adds incoming bytes
|
||||
func (m *Meter) AddIn(n int64) {
|
||||
m.bytesIn.Add(n)
|
||||
}
|
||||
|
||||
// AddOut adds outgoing bytes
|
||||
func (m *Meter) AddOut(n int64) {
|
||||
m.bytesOut.Add(n)
|
||||
}
|
||||
|
||||
// Tick calculates current transfer rate
|
||||
func (m *Meter) Tick() {
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(m.lastTick).Seconds()
|
||||
if elapsed <= 0 {
|
||||
elapsed = 1
|
||||
}
|
||||
|
||||
currIn := m.bytesIn.Load()
|
||||
currOut := m.bytesOut.Load()
|
||||
|
||||
diffIn := currIn - m.lastBytesIn
|
||||
diffOut := currOut - m.lastBytesOut
|
||||
|
||||
m.rateIn.Store(int64(float64(diffIn) / elapsed))
|
||||
m.rateOut.Store(int64(float64(diffOut) / elapsed))
|
||||
|
||||
m.lastBytesIn = currIn
|
||||
m.lastBytesOut = currOut
|
||||
m.lastTick = now
|
||||
}
|
||||
|
||||
// Stats returns snapshot values
|
||||
func (m *Meter) Stats() (bytesIn, bytesOut, rateIn, rateOut int64) {
|
||||
return m.bytesIn.Load(), m.bytesOut.Load(), m.rateIn.Load(), m.rateOut.Load()
|
||||
}
|
||||
|
||||
// Conn wraps a net.Conn with traffic metering
|
||||
type Conn struct {
|
||||
net.Conn
|
||||
meter *Meter
|
||||
}
|
||||
|
||||
// WrapConn wraps a net.Conn
|
||||
func WrapConn(c net.Conn, meter *Meter) *Conn {
|
||||
return &Conn{
|
||||
Conn: c,
|
||||
meter: meter,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) Read(b []byte) (n int, err error) {
|
||||
n, err = c.Conn.Read(b)
|
||||
if n > 0 {
|
||||
c.meter.AddIn(int64(n))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Conn) Write(b []byte) (n int, err error) {
|
||||
n, err = c.Conn.Write(b)
|
||||
if n > 0 {
|
||||
c.meter.AddOut(int64(n))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Pipe copies data bidirectionally between two ReadWriteClosers with buffer pooling
|
||||
func Pipe(c1, c2 io.ReadWriteCloser) {
|
||||
PipeWithLimiter(c1, c2, nil)
|
||||
}
|
||||
|
||||
// Limiter implements a token-bucket rate limiter for bandwidth QoS.
|
||||
// 令牌可以透支,调用方在锁外按欠额休眠,多个连接共享同一个桶时不会互相串行阻塞。
|
||||
type Limiter struct {
|
||||
mbPerSec float64
|
||||
rate float64 // bytes per second
|
||||
capacity float64 // burst capacity
|
||||
tokens float64
|
||||
lastUpdate time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewLimiter creates a limiter with MB/s target
|
||||
func NewLimiter(mbPerSec float64) *Limiter {
|
||||
if mbPerSec <= 0 {
|
||||
return nil
|
||||
}
|
||||
bytesPerSec := mbPerSec * 1024 * 1024
|
||||
return &Limiter{
|
||||
mbPerSec: mbPerSec,
|
||||
rate: bytesPerSec,
|
||||
capacity: bytesPerSec * 2,
|
||||
tokens: bytesPerSec,
|
||||
lastUpdate: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// MBPerSec returns the configured limit (0 for a nil limiter)
|
||||
func (l *Limiter) MBPerSec() float64 {
|
||||
if l == nil {
|
||||
return 0
|
||||
}
|
||||
return l.mbPerSec
|
||||
}
|
||||
|
||||
// reserve takes n tokens and returns how long the caller must wait before proceeding
|
||||
func (l *Limiter) reserve(n int) time.Duration {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
if elapsed := now.Sub(l.lastUpdate).Seconds(); elapsed > 0 {
|
||||
l.tokens += elapsed * l.rate
|
||||
if l.tokens > l.capacity {
|
||||
l.tokens = l.capacity
|
||||
}
|
||||
l.lastUpdate = now
|
||||
}
|
||||
|
||||
l.tokens -= float64(n)
|
||||
if l.tokens >= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(-l.tokens / l.rate * float64(time.Second))
|
||||
}
|
||||
|
||||
// Allow limits byte throughput
|
||||
func (l *Limiter) Allow(n int) {
|
||||
if l == nil || n <= 0 {
|
||||
return
|
||||
}
|
||||
if wait := l.reserve(n); wait > 0 {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
|
||||
// LimitedReader wraps an io.Reader with a Limiter
|
||||
type LimitedReader struct {
|
||||
R io.Reader
|
||||
Limiter *Limiter
|
||||
}
|
||||
|
||||
func (lr *LimitedReader) Read(p []byte) (n int, err error) {
|
||||
n, err = lr.R.Read(p)
|
||||
if n > 0 && lr.Limiter != nil {
|
||||
lr.Limiter.Allow(n)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// PipeWithLimiter copies data bidirectionally between two ReadWriteClosers with optional rate limiting
|
||||
func PipeWithLimiter(c1, c2 io.ReadWriteCloser, limiter *Limiter) {
|
||||
done := make(chan struct{}, 2)
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 32*1024)
|
||||
var r io.Reader = c2
|
||||
if limiter != nil {
|
||||
r = &LimitedReader{R: c2, Limiter: limiter}
|
||||
}
|
||||
_, _ = io.CopyBuffer(c1, r, buf)
|
||||
_ = c1.Close()
|
||||
_ = c2.Close()
|
||||
done <- struct{}{}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 32*1024)
|
||||
var r io.Reader = c1
|
||||
if limiter != nil {
|
||||
r = &LimitedReader{R: c1, Limiter: limiter}
|
||||
}
|
||||
_, _ = io.CopyBuffer(c2, r, buf)
|
||||
_ = c1.Close()
|
||||
_ = c2.Close()
|
||||
done <- struct{}{}
|
||||
}()
|
||||
|
||||
<-done
|
||||
<-done
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package traffic
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLimiterDoesNotSerializeCallers(t *testing.T) {
|
||||
// 1 MB/s,初始令牌 1 MB;两个调用方各取 1 MB,总耗时应约 1 秒而不是被锁串行化
|
||||
l := NewLimiter(1)
|
||||
start := time.Now()
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
l.Allow(1024 * 1024)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if d := time.Since(start); d < 800*time.Millisecond || d > 1500*time.Millisecond {
|
||||
t.Fatalf("expected ~1s total wait, got %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilLimiter(t *testing.T) {
|
||||
var l *Limiter
|
||||
l.Allow(1 << 20)
|
||||
if l.MBPerSec() != 0 {
|
||||
t.Fatal("nil limiter should report 0")
|
||||
}
|
||||
if NewLimiter(0) != nil {
|
||||
t.Fatal("NewLimiter(0) should be nil (unlimited)")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user