feat: relay connection limits and container memory caps

This commit is contained in:
edde746
2026-04-13 07:55:28 +02:00
parent 3be302d69f
commit 6dfdb62df8
3 changed files with 162 additions and 7 deletions
+9 -1
View File
@@ -1,5 +1,13 @@
ice.plezy.app { ice.plezy.app {
reverse_proxy relay:8080 log {
output stderr
level ERROR
}
reverse_proxy relay:8080 {
transport http {
max_conns_per_host 100
}
}
} }
bugs.plezy.app { bugs.plezy.app {
+18
View File
@@ -2,14 +2,21 @@ services:
relay: relay:
build: . build: .
restart: unless-stopped restart: unless-stopped
mem_limit: 128m
volumes: volumes:
- logs:/data/logs - logs:/data/logs
expose: expose:
- "8080" - "8080"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
caddy: caddy:
image: caddy:2-alpine image: caddy:2-alpine
restart: unless-stopped restart: unless-stopped
mem_limit: 128m
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
@@ -17,10 +24,16 @@ services:
- ./Caddyfile:/etc/caddy/Caddyfile:ro - ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data - caddy_data:/data
- caddy_config:/config - caddy_config:/config
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
bugs: bugs:
image: ghcr.io/edde746/bugs:latest image: ghcr.io/edde746/bugs:latest
restart: unless-stopped restart: unless-stopped
mem_limit: 128m
volumes: volumes:
- bugs_data:/data - bugs_data:/data
environment: environment:
@@ -29,6 +42,11 @@ services:
BUGS_RETENTION_DAYS: 90 BUGS_RETENTION_DAYS: 90
expose: expose:
- "9000" - "9000"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes: volumes:
logs: logs:
+135 -6
View File
@@ -35,6 +35,11 @@ const (
logIDLength = 5 logIDLength = 5
logRateInterval = 1 * time.Minute logRateInterval = 1 * time.Minute
maxLogEntries = 500 maxLogEntries = 500
maxConnsPerIP = 5
maxGlobalConns = 100
maxRoomsPerIP = 3
connRateBurst = 5
connRateSustained = 1
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
@@ -82,6 +87,95 @@ func (rl *rateLimiter) allow() bool {
return true return true
} }
// --- Connection tracker (per-IP limits) ---
type connTracker struct {
mu sync.Mutex
perIP map[string]int
ipRate map[string]*rateLimiter
roomsPerIP map[string]int
globalCount int
}
func newConnTracker() *connTracker {
return &connTracker{
perIP: make(map[string]int),
ipRate: make(map[string]*rateLimiter),
roomsPerIP: make(map[string]int),
}
}
func (ct *connTracker) tryConnect(ip string) bool {
ct.mu.Lock()
defer ct.mu.Unlock()
if ct.globalCount >= maxGlobalConns {
return false
}
if ct.perIP[ip] >= maxConnsPerIP {
return false
}
rl, ok := ct.ipRate[ip]
if !ok {
rl = newRateLimiter(connRateBurst, connRateSustained)
ct.ipRate[ip] = rl
}
// Unlock ct.mu before calling rl.allow() would be cleaner,
// but since rl has its own mutex this is safe (no deadlock).
if !rl.allow() {
return false
}
ct.perIP[ip]++
ct.globalCount++
return true
}
func (ct *connTracker) disconnect(ip string) {
ct.mu.Lock()
defer ct.mu.Unlock()
if ct.perIP[ip] > 0 {
ct.perIP[ip]--
ct.globalCount--
}
if ct.perIP[ip] == 0 {
delete(ct.perIP, ip)
}
}
func (ct *connTracker) tryCreateRoom(ip string) bool {
ct.mu.Lock()
defer ct.mu.Unlock()
if ct.roomsPerIP[ip] >= maxRoomsPerIP {
return false
}
ct.roomsPerIP[ip]++
return true
}
func (ct *connTracker) releaseRoom(ip string) {
ct.mu.Lock()
defer ct.mu.Unlock()
if ct.roomsPerIP[ip] > 0 {
ct.roomsPerIP[ip]--
}
if ct.roomsPerIP[ip] == 0 {
delete(ct.roomsPerIP, ip)
}
}
func (ct *connTracker) cleanup() {
ct.mu.Lock()
defer ct.mu.Unlock()
for ip := range ct.ipRate {
if ct.perIP[ip] == 0 {
delete(ct.ipRate, ip)
}
}
}
// --- Messages --- // --- Messages ---
type clientMsg struct { type clientMsg struct {
@@ -220,11 +314,12 @@ func (ls *logStore) cleanup() {
type Server struct { type Server struct {
rooms map[string]*Room rooms map[string]*Room
logs *logStore logs *logStore
conns *connTracker
mu sync.RWMutex mu sync.RWMutex
} }
func newServer(logDir string) *Server { func newServer(logDir string) *Server {
s := &Server{rooms: make(map[string]*Room), logs: newLogStore(logDir)} s := &Server{rooms: make(map[string]*Room), logs: newLogStore(logDir), conns: newConnTracker()}
go s.cleanupLoop() go s.cleanupLoop()
return s return s
} }
@@ -248,18 +343,34 @@ func (s *Server) cleanupLoop() {
} }
s.mu.Unlock() s.mu.Unlock()
s.logs.cleanup() s.logs.cleanup()
s.conns.cleanup()
s.conns.mu.Lock()
log.Printf("stats: conns=%d ips=%d rooms=%d",
s.conns.globalCount, len(s.conns.perIP), len(s.rooms))
s.conns.mu.Unlock()
} }
} }
func clientIP(r *http.Request) string { func clientIP(r *http.Request) string {
var raw string
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
return strings.SplitN(fwd, ",", 2)[0] raw = strings.TrimSpace(strings.SplitN(fwd, ",", 2)[0])
} else {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
raw = r.RemoteAddr
} else {
raw = host
}
} }
host, _, err := net.SplitHostPort(r.RemoteAddr) // Normalize IPv6 to /64 prefix to prevent per-address bypass
if err != nil { ip := net.ParseIP(raw)
return r.RemoteAddr if ip != nil && ip.To4() == nil {
mask := net.CIDRMask(64, 128)
return ip.Mask(mask).String()
} }
return host return raw
} }
func (s *Server) handlePostLogs(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePostLogs(w http.ResponseWriter, r *http.Request) {
@@ -365,6 +476,14 @@ func (s *Server) sendJSON(conn *websocket.Conn, msg serverMsg) {
} }
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
if !s.conns.tryConnect(ip) {
http.Error(w, "Too many connections", http.StatusTooManyRequests)
return
}
defer s.conns.disconnect(ip)
conn, err := upgrader.Upgrade(w, r, nil) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Printf("upgrade error: %v", err) log.Printf("upgrade error: %v", err)
@@ -394,6 +513,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
rl := newRateLimiter(rateBurst, rateSustained) rl := newRateLimiter(rateBurst, rateSustained)
var currentRoom *Room var currentRoom *Room
var currentPeerID string var currentPeerID string
var isHost bool
// Cleanup on disconnect // Cleanup on disconnect
defer func() { defer func() {
@@ -405,6 +525,9 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
Type: "peerLeft", Type: "peerLeft",
PeerID: currentPeerID, PeerID: currentPeerID,
}) })
if isHost {
s.conns.releaseRoom(ip)
}
log.Printf("peer %s left room %s", currentPeerID, currentRoom.SessionID) log.Printf("peer %s left room %s", currentPeerID, currentRoom.SessionID)
} }
}() }()
@@ -435,9 +558,14 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
s.sendError(conn, "invalid_message", "sessionId and peerId required") s.sendError(conn, "invalid_message", "sessionId and peerId required")
continue continue
} }
if !s.conns.tryCreateRoom(ip) {
s.sendError(conn, "rate_limited", "Too many rooms created")
continue
}
s.mu.Lock() s.mu.Lock()
if _, exists := s.rooms[msg.SessionID]; exists { if _, exists := s.rooms[msg.SessionID]; exists {
s.mu.Unlock() s.mu.Unlock()
s.conns.releaseRoom(ip)
s.sendError(conn, "room_exists", "Room already exists") s.sendError(conn, "room_exists", "Room already exists")
continue continue
} }
@@ -451,6 +579,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
s.mu.Unlock() s.mu.Unlock()
currentRoom = room currentRoom = room
currentPeerID = msg.PeerID currentPeerID = msg.PeerID
isHost = true
log.Printf("room %s created by %s", msg.SessionID, msg.PeerID) log.Printf("room %s created by %s", msg.SessionID, msg.PeerID)
s.sendJSON(conn, serverMsg{Type: "created", SessionID: msg.SessionID}) s.sendJSON(conn, serverMsg{Type: "created", SessionID: msg.SessionID})