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:
yuanyuanxianganddeepseek-v4-pro committed 2026-10-13 16:25:34 +02:00
1 parent 2de21b2b6b
commit 8e0f1283c6
4 files changed
+344 -17

No files matched your search

+93
View File
@@ -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)
}
}
}