mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
fix(web): bound the panel's open connections and share asset gzip
The engine-wide gzip middleware gave every in-flight request its own compressor of about 1 MiB, and the public /assets bundles let an anonymous client keep a handler blocked in Write until WriteTimeout, so a thousand stalled downloads held a gigabyte. The listener itself took any number of connections, each holding buffers and a goroutine before any login. The bundles are now served from a gzip copy made once per file, with the assets prefix left out of the per-request middleware, and the panel listener closes a new connection once 2048 are open or its source (an IPv4 address or IPv6 /64) holds 256; a loopback reverse proxy counts only toward the total.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Every open panel connection holds buffers and a goroutine before any login, so their
|
||||
// number is bounded overall and per source; a loopback reverse proxy counts only overall.
|
||||
const (
|
||||
MaxPanelConns = 2048
|
||||
MaxPanelConnsPerSource = 256
|
||||
)
|
||||
|
||||
type limitListener struct {
|
||||
net.Listener
|
||||
total, perSource int
|
||||
|
||||
mu sync.Mutex
|
||||
open int
|
||||
bySource map[netip.Prefix]int
|
||||
}
|
||||
|
||||
// NewLimitListener closes an accepted connection at once when the total or its source
|
||||
// (an IPv4 address or IPv6 /64) is at its limit, rather than leaving it in the backlog.
|
||||
func NewLimitListener(l net.Listener, total, perSource int) net.Listener {
|
||||
return &limitListener{Listener: l, total: total, perSource: perSource, bySource: map[netip.Prefix]int{}}
|
||||
}
|
||||
|
||||
func (l *limitListener) Accept() (net.Conn, error) {
|
||||
for {
|
||||
c, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source, counted := connSource(c.RemoteAddr())
|
||||
if !l.acquire(source, counted) {
|
||||
_ = c.Close()
|
||||
continue
|
||||
}
|
||||
return &limitConn{Conn: c, release: func() { l.release(source, counted) }}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (l *limitListener) acquire(source netip.Prefix, counted bool) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.open >= l.total || (counted && l.bySource[source] >= l.perSource) {
|
||||
return false
|
||||
}
|
||||
l.open++
|
||||
if counted {
|
||||
l.bySource[source]++
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *limitListener) release(source netip.Prefix, counted bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.open--
|
||||
if counted {
|
||||
if l.bySource[source]--; l.bySource[source] <= 0 {
|
||||
delete(l.bySource, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// connSource is the per-source bucket of addr; loopback and unparseable sources have none.
|
||||
func connSource(addr net.Addr) (netip.Prefix, bool) {
|
||||
ap, err := netip.ParseAddrPort(addr.String())
|
||||
if err != nil {
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
ip := ap.Addr().Unmap()
|
||||
if ip.IsLoopback() {
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
bits := 32
|
||||
if ip.Is6() {
|
||||
bits = 64
|
||||
}
|
||||
prefix, err := ip.Prefix(bits)
|
||||
return prefix, err == nil
|
||||
}
|
||||
|
||||
type limitConn struct {
|
||||
net.Conn
|
||||
once sync.Once
|
||||
release func()
|
||||
}
|
||||
|
||||
func (c *limitConn) Close() error {
|
||||
err := c.Conn.Close()
|
||||
c.once.Do(c.release)
|
||||
return err
|
||||
}
|
||||
@@ -109,3 +109,80 @@ func TestProductionHTTPServersUseServeHTTPWrapper(t *testing.T) {
|
||||
t.Fatalf("scan production Go files: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// queueListener hands out conns from a channel, each claiming the given remote address.
|
||||
type queueListener struct{ conns chan net.Conn }
|
||||
|
||||
func (l queueListener) Accept() (net.Conn, error) {
|
||||
c, ok := <-l.conns
|
||||
if !ok {
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
func (queueListener) Close() error { return nil }
|
||||
func (queueListener) Addr() net.Addr { return testAddr("queue") }
|
||||
|
||||
type remoteConn struct {
|
||||
net.Conn
|
||||
remote net.Addr
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func (c *remoteConn) RemoteAddr() net.Addr { return c.remote }
|
||||
func (c *remoteConn) Close() error { close(c.closed); return nil }
|
||||
|
||||
func newRemoteConn(addr string) *remoteConn {
|
||||
return &remoteConn{remote: &net.TCPAddr{IP: net.ParseIP(addr), Port: 40000}, closed: make(chan struct{})}
|
||||
}
|
||||
|
||||
// Every panel connection holds buffers before any login, so one source opening
|
||||
// thousands of them used to take the whole panel's memory.
|
||||
func TestLimitListenerCapsConnectionsPerSourceAndInTotal(t *testing.T) {
|
||||
queue := queueListener{conns: make(chan net.Conn, 16)}
|
||||
l := NewLimitListener(queue, 4, 2)
|
||||
accept := func(c *remoteConn) net.Conn {
|
||||
t.Helper()
|
||||
queue.conns <- c
|
||||
accepted, err := l.Accept()
|
||||
if err != nil {
|
||||
t.Fatalf("Accept: %v", err)
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
first := accept(newRemoteConn("203.0.113.1"))
|
||||
accept(newRemoteConn("203.0.113.1"))
|
||||
refused := newRemoteConn("203.0.113.1")
|
||||
queue.conns <- refused
|
||||
other := accept(newRemoteConn("2001:db8::1"))
|
||||
select {
|
||||
case <-refused.closed:
|
||||
default:
|
||||
t.Fatal("a source past its share was accepted")
|
||||
}
|
||||
if other.RemoteAddr().String() != "[2001:db8::1]:40000" {
|
||||
t.Fatalf("accepted %s, want the other source", other.RemoteAddr())
|
||||
}
|
||||
|
||||
// Loopback is exempt from the per-source share but counts toward the total.
|
||||
accept(newRemoteConn("127.0.0.1"))
|
||||
overTotal := newRemoteConn("127.0.0.1")
|
||||
queue.conns <- overTotal
|
||||
next := make(chan net.Conn, 1)
|
||||
go func() {
|
||||
c, _ := l.Accept()
|
||||
next <- c
|
||||
}()
|
||||
select {
|
||||
case <-overTotal.closed:
|
||||
case c := <-next:
|
||||
t.Fatalf("a connection past the total was accepted: %s", c.RemoteAddr())
|
||||
}
|
||||
_ = first.Close()
|
||||
queue.conns <- newRemoteConn("203.0.113.1")
|
||||
again := <-next
|
||||
if again.RemoteAddr().String() != "203.0.113.1:40000" {
|
||||
t.Fatalf("a closed connection did not free its slot: accepted %s", again.RemoteAddr())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user