feat: relay connection limits and container memory caps
This commit is contained in:
+9
-1
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user