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:
MHSanaei
2026-11-01 01:01:38 +01:00
parent 1c748adadf
commit 3526a65f5d
7 changed files with 436 additions and 11 deletions
+11 -10
View File
@@ -66,16 +66,14 @@ type cronPanicLogger struct{}
func (cronPanicLogger) Printf(format string, args ...any) { logger.Errorf(format, args...) }
// wrapDistFS adapts the embedded `dist/` directory so it can be mounted
// as the panel's `/assets/` static route. Vite emits its bundled JS/CSS
// under `dist/assets/`; serving the FS rooted at `dist/assets` makes
// `/assets/<hash>.js` URLs resolve directly.
// wrapDistFS serves the embedded `dist/assets` tree (Vite's bundled JS/CSS) as the panel's
// `/assets/` route; a named field keeps embed.FS's ReadFile, rooted at `dist`, unpromoted.
type wrapDistFS struct {
embed.FS
dist embed.FS
}
func (f *wrapDistFS) Open(name string) (fs.File, error) {
file, err := f.FS.Open("dist/assets/" + name)
file, err := f.dist.Open("dist/assets/" + name)
if err != nil {
return nil, err
}
@@ -205,8 +203,10 @@ func (s *Server) initRouter() (*gin.Engine, error) {
if err != nil {
return nil, err
}
engine.Use(gzip.Gzip(gzip.DefaultCompression))
assetsBasePath := basePath + "assets/"
// The bundles are served below from a gzip copy made once per file: compressing them
// per request lets every stalled download pin its own compressor until WriteTimeout.
engine.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{assetsBasePath})))
store := cookie.NewStore(secret)
// Configure default session cookie options, including expiration (MaxAge)
@@ -244,11 +244,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
// so the Vite watcher's incremental rebuilds show up without
// restarting the binary; in prod we serve the embedded dist FS
// rooted at `dist/assets/`.
var assetsFS fs.FS = &wrapDistFS{dist: distFS}
if config.IsDebug() {
engine.StaticFS(basePath+"assets", http.FS(os.DirFS("internal/web/dist/assets")))
} else {
engine.StaticFS(basePath+"assets", http.FS(&wrapDistFS{FS: distFS}))
assetsFS = os.DirFS("internal/web/dist/assets")
}
engine.Group(basePath+"assets", middleware.GzipStatic(assetsFS)).StaticFS("/", http.FS(assetsFS))
// Hand the embedded `dist/` filesystem to the controller package
// before any HTML-serving controller is constructed. Phase 8
@@ -606,6 +606,7 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
if err != nil {
return err
}
listener = network.NewLimitListener(listener, network.MaxPanelConns, network.MaxPanelConnsPerSource)
if certFile != "" || keyFile != "" {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err == nil {