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
+327

No files matched your search

+48
View File
@@ -31,6 +31,7 @@ type MyHandler struct {
srv *server.Server srv *server.Server
hub *hub.Hub hub *hub.Hub
signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop) signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop)
loginLimit *wsauth.RateLimiter // per-IP TOKEN_AUTH throttle
} }
// OnConnect is called when a client connects // OnConnect is called when a client connects
@@ -497,6 +498,20 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client
// handleAuth handles authorization request (TOKEN_AUTH = 100) // handleAuth handles authorization request (TOKEN_AUTH = 100)
func (h *MyHandler) handleAuth(ctx *connection.Context, data []byte) { func (h *MyHandler) handleAuth(ctx *connection.Context, data []byte) {
// Per-IP throttle on TOKEN_AUTH: rate-limit online passcode-guessing
// attempts against the device authorization protocol. On exceed we answer
// with a normal "failed" response (resp[0]=0) so the client gets a
// definitive answer instead of hanging, while a brute-forcer is capped.
ip := ctx.GetPeerIP()
if !h.loginLimit.Allow(ip) {
h.log.Warn("TOKEN_AUTH rate-limited: ip=%s conn=%d", ip, ctx.ID)
resp := h.auth.BuildResponse(&auth.AuthResult{Valid: false, Message: "尝试过于频繁,请稍后再试"})
if err := h.srv.Send(ctx, resp); err != nil {
h.log.Error("Failed to send rate-limit auth response to client %d: %v", ctx.ID, err)
}
return
}
result := h.auth.Authenticate(data) result := h.auth.Authenticate(data)
info := ctx.GetInfo() info := ctx.GetInfo()
@@ -674,6 +689,19 @@ func normalizeBasePath(s string) string {
return "/" + s return "/" + s
} }
// envInt parses an integer env var, returning def when unset or malformed.
func envInt(name string, def int) int {
v := strings.TrimSpace(os.Getenv(name))
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
// runIssueToken handles the "issue-token" subcommand. It mints a customer JWT // runIssueToken handles the "issue-token" subcommand. It mints a customer JWT
// signed with the operator's RSA private key and prints it to stdout. // signed with the operator's RSA private key and prints it to stdout.
// //
@@ -875,12 +903,31 @@ func main() {
log.Warn("Failed to load %s: %v (continuing with admin only)", usersFile, err) log.Warn("Failed to load %s: %v (continuing with admin only)", usersFile, err)
} }
// TCP authorization (TOKEN_AUTH) brute-force throttle. Keyed by source IP
// (the real TCP peer — there is no X-Forwarded-For on the device port).
// Generous enough that a NAT office whose devices all re-auth at once is
// unaffected, tight enough to make online passcode guessing impractical.
authLoginLimit := wsauth.NewRateLimiter(120, time.Minute)
// Bounded-key hygiene: prune aged-out entries so the per-IP map cannot grow
// without bound. Runs for the process lifetime (a daemon goroutine; the
// process exits via the SIGINT/SIGTERM handler at the bottom of main).
go func() {
t := time.NewTicker(time.Minute)
defer t.Stop()
for range t.C {
authLoginLimit.Sweep()
}
}()
// Create servers for each port // Create servers for each port
var servers []*server.Server var servers []*server.Server
for _, port := range ports { for _, port := range ports {
config := server.DefaultConfig() config := server.DefaultConfig()
config.Port = port config.Port = port
config.MaxConnections = 9999 config.MaxConnections = 9999
config.PerIPMaxConnections = envInt("YAMA_PERIP_MAX_CONNS", config.PerIPMaxConnections)
config.PerIPConnRateLimit = envInt("YAMA_PERIP_CONN_RATE", config.PerIPConnRateLimit)
srv := server.New(config) srv := server.New(config)
srv.SetLogger(log.WithPrefix(fmt.Sprintf("Server:%d", port))) srv.SetLogger(log.WithPrefix(fmt.Sprintf("Server:%d", port)))
@@ -892,6 +939,7 @@ func main() {
srv: srv, srv: srv,
hub: deviceHub, hub: deviceHub,
signer: signer, signer: signer,
loginLimit: authLoginLimit,
} }
srv.SetHandler(handler) srv.SetHandler(handler)
+154
View File
@@ -22,6 +22,15 @@ type Config struct {
KeepAliveTime time.Duration KeepAliveTime time.Duration
ReadTimeout time.Duration ReadTimeout time.Duration
WriteTimeout 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 // DefaultConfig returns default configuration
@@ -34,6 +43,9 @@ func DefaultConfig() Config {
KeepAliveTime: time.Minute * 5, KeepAliveTime: time.Minute * 5,
ReadTimeout: time.Minute * 2, ReadTimeout: time.Minute * 2,
WriteTimeout: time.Second * 30, WriteTimeout: time.Second * 30,
PerIPMaxConnections: 256,
PerIPConnRateLimit: 120,
PerIPConnRateWindow: time.Minute,
} }
} }
@@ -51,6 +63,7 @@ type Server struct {
manager *connection.Manager manager *connection.Manager
handler Handler handler Handler
parser *protocol.Parser parser *protocol.Parser
ipLimit *ipLimiter
running atomic.Bool running atomic.Bool
wg sync.WaitGroup wg sync.WaitGroup
@@ -69,6 +82,7 @@ func New(config Config) *Server {
config: config, config: config,
manager: connection.NewManager(config.MaxConnections), manager: connection.NewManager(config.MaxConnections),
parser: protocol.NewParser(), parser: protocol.NewParser(),
ipLimit: newIPLimiter(config.PerIPMaxConnections, config.PerIPConnRateLimit, config.PerIPConnRateWindow),
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
logger: logger.New(logger.DefaultConfig()).WithPrefix("Server"), logger: logger.New(logger.DefaultConfig()).WithPrefix("Server"),
@@ -77,6 +91,110 @@ func New(config Config) *Server {
return s 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 // SetHandler sets the event handler
func (s *Server) SetHandler(h Handler) { func (s *Server) SetHandler(h Handler) {
s.handler = h s.handler = h
@@ -120,9 +238,32 @@ func (s *Server) Start() error {
s.wg.Add(1) s.wg.Add(1)
go s.acceptLoop() 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 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 { func itoa(i int) string {
if i == 0 { if i == 0 {
return "0" return "0"
@@ -194,6 +335,15 @@ func (s *Server) acceptLoop() {
continue 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 // Handle each connection in its own goroutine
go s.handleConnection(conn) go s.handleConnection(conn)
} }
@@ -204,6 +354,10 @@ func (s *Server) handleConnection(conn net.Conn) {
// Create context // Create context
ctx := connection.NewContext(conn, nil) 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 // Add to manager
if err := s.manager.Add(ctx); err != nil { if err := s.manager.Add(ctx); err != nil {
s.logger.Warn("Failed to add connection: %v", err) s.logger.Warn("Failed to add connection: %v", err)
+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)
}
}
}
+32
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub" "github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
@@ -33,6 +34,8 @@ type Server struct {
trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only) trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only)
basePath string // web UI mount prefix (e.g. "/remoter"); "" = domain root basePath string // web UI mount prefix (e.g. "/remoter"); "" = domain root
sso SSODetector // nil = SSO disabled sso SSODetector // nil = SSO disabled
sweepDone chan struct{} // closes to stop the login-limiter sweeper
sweepStopOnce sync.Once
} }
// Config tunes the server's exposed-on-public-HTTPS hardening knobs. // Config tunes the server's exposed-on-public-HTTPS hardening knobs.
@@ -130,11 +133,40 @@ func (s *Server) Start() error {
s.log.Error("HTTP server stopped: %v", err) s.log.Error("HTTP server stopped: %v", err)
} }
}() }()
// Prune aged-out login rate-limit entries so the key maps (especially the
// username-keyed one, which is attacker-controlled) stay bounded.
s.sweepDone = make(chan struct{})
go s.sweepLoginLimiters(s.sweepDone)
return nil return nil
} }
// sweepLoginLimiters periodically sweeps both web login rate limiters. Runs
// until `stop` is closed (from Stop).
func (s *Server) sweepLoginLimiters(stop <-chan struct{}) {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
if s.loginIPLimit != nil {
s.loginIPLimit.Sweep()
}
if s.loginUserLimit != nil {
s.loginUserLimit.Sweep()
}
}
}
}
// Stop gracefully shuts the server down. // Stop gracefully shuts the server down.
func (s *Server) Stop() { func (s *Server) Stop() {
if s.sweepDone != nil {
s.sweepStopOnce.Do(func() { close(s.sweepDone) })
}
if s.ws != nil { if s.ws != nil {
s.ws.stop() s.ws.stop()
} }