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

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

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

No files matched your search

+193
View File
@@ -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
}
+114
View File
@@ -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")
}
}
+305
View File
@@ -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"`
}
+210
View File
@@ -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
}
+36
View File
@@ -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)")
}
}