Security: Add per-IP connection and login rate limiting
The TCP device port is exposed directly to the public internet, so a single host must not be able to exhaust the global connection budget, flood new connection attempts, or brute-force the device authorization passcode. - server: cap concurrent connections and the accept rate per source IP (defaults 256 concurrent / 120 per minute, overridable via YAMA_PERIP_MAX_CONNS and YAMA_PERIP_CONN_RATE). Rejections happen at accept time, before any buffer or goroutine is allocated, and a rejected device simply retries on its reconnect loop. - cmd: throttle TOKEN_AUTH per source IP (120/min) and answer over-limit attempts with a normal failed response so clients get a definitive reply. - web: periodically sweep the login rate-limit maps so the username-keyed one (which is attacker-controlled) cannot grow without bound. Per-IP defaults are generous enough that a NAT office whose devices share one source IP is unaffected. Co-Authored-By: deepseek-v4-pro
This commit is contained in:
1 parent
2de21b2b6b
commit
8e0f1283c6
4 files changed
+344
-17
No files matched your search
+161
-7
@@ -22,18 +22,30 @@ type Config struct {
|
||||
KeepAliveTime time.Duration
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
|
||||
// Per-IP connection limits. The TCP listener (port 6543) is exposed
|
||||
// directly to the public internet, so a single host must not be able to
|
||||
// exhaust the global connection budget or flood new-connection attempts.
|
||||
// Defaults are generous enough for a NAT office with dozens of devices
|
||||
// sharing one source IP; raise them if you run a larger NAT deployment.
|
||||
PerIPMaxConnections int // max concurrent connections per source IP (0 = unlimited)
|
||||
PerIPConnRateLimit int // max new connections per PerIPConnRateWindow per IP (0 = unlimited)
|
||||
PerIPConnRateWindow time.Duration // window for PerIPConnRateLimit
|
||||
}
|
||||
|
||||
// DefaultConfig returns default configuration
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Port: 6543,
|
||||
MaxConnections: 9999,
|
||||
ReadBufferSize: 8192,
|
||||
WriteBufferSize: 8192,
|
||||
KeepAliveTime: time.Minute * 5,
|
||||
ReadTimeout: time.Minute * 2,
|
||||
WriteTimeout: time.Second * 30,
|
||||
Port: 6543,
|
||||
MaxConnections: 9999,
|
||||
ReadBufferSize: 8192,
|
||||
WriteBufferSize: 8192,
|
||||
KeepAliveTime: time.Minute * 5,
|
||||
ReadTimeout: time.Minute * 2,
|
||||
WriteTimeout: time.Second * 30,
|
||||
PerIPMaxConnections: 256,
|
||||
PerIPConnRateLimit: 120,
|
||||
PerIPConnRateWindow: time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +63,7 @@ type Server struct {
|
||||
manager *connection.Manager
|
||||
handler Handler
|
||||
parser *protocol.Parser
|
||||
ipLimit *ipLimiter
|
||||
|
||||
running atomic.Bool
|
||||
wg sync.WaitGroup
|
||||
@@ -69,6 +82,7 @@ func New(config Config) *Server {
|
||||
config: config,
|
||||
manager: connection.NewManager(config.MaxConnections),
|
||||
parser: protocol.NewParser(),
|
||||
ipLimit: newIPLimiter(config.PerIPMaxConnections, config.PerIPConnRateLimit, config.PerIPConnRateWindow),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
logger: logger.New(logger.DefaultConfig()).WithPrefix("Server"),
|
||||
@@ -77,6 +91,110 @@ func New(config Config) *Server {
|
||||
return s
|
||||
}
|
||||
|
||||
// ipLimiter caps concurrent connections and the rate of new connections per
|
||||
// source IP. Both limits are per-host, not global, so a single attacker (or a
|
||||
// single misbehaving host) cannot starve legitimate devices of the global
|
||||
// connection budget.
|
||||
type ipLimiter struct {
|
||||
mu sync.Mutex
|
||||
maxConcurrent int
|
||||
window time.Duration
|
||||
maxPerWindow int
|
||||
concurrent map[string]int
|
||||
recent map[string][]time.Time
|
||||
}
|
||||
|
||||
func newIPLimiter(maxConcurrent, maxPerWindow int, window time.Duration) *ipLimiter {
|
||||
return &ipLimiter{
|
||||
maxConcurrent: maxConcurrent,
|
||||
maxPerWindow: maxPerWindow,
|
||||
window: window,
|
||||
concurrent: make(map[string]int),
|
||||
recent: make(map[string][]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// allow records a new connection from ip and reports whether it is permitted.
|
||||
// It enforces both a concurrent cap and a rolling accept-rate cap. An empty ip
|
||||
// (which should not occur for a real TCP peer) is not throttled.
|
||||
func (l *ipLimiter) allow(ip string) bool {
|
||||
if ip == "" {
|
||||
return true
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.maxConcurrent > 0 && l.concurrent[ip] >= l.maxConcurrent {
|
||||
return false
|
||||
}
|
||||
|
||||
if l.maxPerWindow > 0 && l.window > 0 {
|
||||
cutoff := now.Add(-l.window)
|
||||
times := l.recent[ip]
|
||||
keep := times[:0]
|
||||
for _, t := range times {
|
||||
if t.After(cutoff) {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
if len(keep) >= l.maxPerWindow {
|
||||
l.recent[ip] = keep
|
||||
return false
|
||||
}
|
||||
l.recent[ip] = append(keep, now)
|
||||
}
|
||||
|
||||
l.concurrent[ip]++
|
||||
return true
|
||||
}
|
||||
|
||||
// release decrements the concurrent count for ip. Safe to call on an ip that
|
||||
// was never allowed.
|
||||
func (l *ipLimiter) release(ip string) {
|
||||
if ip == "" {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
if n := l.concurrent[ip]; n > 1 {
|
||||
l.concurrent[ip] = n - 1
|
||||
} else {
|
||||
delete(l.concurrent, ip)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// sweep drops per-IP rate-limit entries whose timestamps have all aged out,
|
||||
// keeping the map bounded over a long-running process.
|
||||
func (l *ipLimiter) sweep() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
cutoff := time.Now().Add(-l.window)
|
||||
for ip, times := range l.recent {
|
||||
keep := times[:0]
|
||||
for _, t := range times {
|
||||
if t.After(cutoff) {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
if len(keep) == 0 {
|
||||
delete(l.recent, ip)
|
||||
} else {
|
||||
l.recent[ip] = keep
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// peerIP returns the host part of a net.Addr's string form, mirroring
|
||||
// connection.Context.GetPeerIP so accept-time and release-time keys agree.
|
||||
func peerIP(addr net.Addr) string {
|
||||
host, _, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
return addr.String()
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// SetHandler sets the event handler
|
||||
func (s *Server) SetHandler(h Handler) {
|
||||
s.handler = h
|
||||
@@ -120,9 +238,32 @@ func (s *Server) Start() error {
|
||||
s.wg.Add(1)
|
||||
go s.acceptLoop()
|
||||
|
||||
// Periodically prune aged-out per-IP rate-limit entries so the map stays
|
||||
// bounded over a long-running process. Stopped by s.ctx.Done() in Stop().
|
||||
s.wg.Add(1)
|
||||
go s.ipSweepLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) ipSweepLoop() {
|
||||
defer s.wg.Done()
|
||||
interval := s.config.PerIPConnRateWindow
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.ipLimit.sweep()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
@@ -194,6 +335,15 @@ func (s *Server) acceptLoop() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Per-IP connection cap + accept rate. Rejecting here (before any
|
||||
// buffer allocation or goroutine) is cheap, and a rejected device
|
||||
// simply retries on its reconnect loop.
|
||||
if !s.ipLimit.allow(peerIP(conn.RemoteAddr())) {
|
||||
s.logger.Warn("Per-IP connection limit reached, rejecting %s", conn.RemoteAddr())
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle each connection in its own goroutine
|
||||
go s.handleConnection(conn)
|
||||
}
|
||||
@@ -204,6 +354,10 @@ func (s *Server) handleConnection(conn net.Conn) {
|
||||
// Create context
|
||||
ctx := connection.NewContext(conn, nil)
|
||||
|
||||
// Always release the per-IP slot on every exit path, including the
|
||||
// manager.Add failure below (a rare accept/add race).
|
||||
defer s.ipLimit.release(ctx.GetPeerIP())
|
||||
|
||||
// Add to manager
|
||||
if err := s.manager.Add(ctx); err != nil {
|
||||
s.logger.Warn("Failed to add connection: %v", err)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIPLimiterConcurrentCap(t *testing.T) {
|
||||
l := newIPLimiter(2, 0, 0) // 2 concurrent, no rate limit
|
||||
|
||||
if !l.allow("10.0.0.1") {
|
||||
t.Fatal("first connection from an IP should be allowed")
|
||||
}
|
||||
if !l.allow("10.0.0.1") {
|
||||
t.Fatal("second connection from an IP should be allowed")
|
||||
}
|
||||
if l.allow("10.0.0.1") {
|
||||
t.Fatal("third concurrent connection from the same IP should be rejected")
|
||||
}
|
||||
|
||||
// A different IP is unaffected.
|
||||
if !l.allow("10.0.0.2") {
|
||||
t.Fatal("a different IP should not share the first IP's cap")
|
||||
}
|
||||
|
||||
// Releasing one slot frees capacity again.
|
||||
l.release("10.0.0.1")
|
||||
if !l.allow("10.0.0.1") {
|
||||
t.Fatal("after release, the IP should have a free slot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPLimiterRateLimit(t *testing.T) {
|
||||
l := newIPLimiter(0, 3, time.Minute) // no concurrent cap, 3/min per IP
|
||||
|
||||
for range 3 {
|
||||
if !l.allow("10.0.0.1") {
|
||||
t.Fatal("an attempt within budget should be allowed")
|
||||
}
|
||||
}
|
||||
if l.allow("10.0.0.1") {
|
||||
t.Fatal("4th connection within the window should be rejected")
|
||||
}
|
||||
// A different IP has its own budget.
|
||||
if !l.allow("10.0.0.2") {
|
||||
t.Fatal("a different IP should have its own rate budget")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPLimiterEmptyIPBypasses(t *testing.T) {
|
||||
l := newIPLimiter(1, 1, time.Minute)
|
||||
// An empty peer IP (shouldn't happen for real TCP) must not be throttled,
|
||||
// so a caller that loses the IP falls back to accepting.
|
||||
if !l.allow("") {
|
||||
t.Fatal("empty IP should bypass the limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPLimiterSweep(t *testing.T) {
|
||||
l := newIPLimiter(0, 1, time.Minute)
|
||||
|
||||
l.mu.Lock()
|
||||
l.recent["stale"] = []time.Time{time.Now().Add(-time.Hour)}
|
||||
l.recent["fresh"] = []time.Time{time.Now()}
|
||||
l.mu.Unlock()
|
||||
|
||||
l.sweep()
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if _, ok := l.recent["stale"]; ok {
|
||||
t.Fatal("stale rate-limit entry should be swept")
|
||||
}
|
||||
if _, ok := l.recent["fresh"]; !ok {
|
||||
t.Fatal("fresh rate-limit entry should survive sweep")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
addr net.Addr
|
||||
want string
|
||||
}{
|
||||
{&net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 1234}, "1.2.3.4"},
|
||||
{&net.TCPAddr{IP: net.ParseIP("::1"), Port: 6543}, "::1"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := peerIP(tt.addr); got != tt.want {
|
||||
t.Fatalf("peerIP(%v) = %q, want %q", tt.addr, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user