-
Notifications
You must be signed in to change notification settings - Fork 3.1k
fix: harden core memory session handling #12366
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,114 +1,248 @@ | ||
| package psession | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "crypto/rand" | ||
| "encoding/hex" | ||
| "errors" | ||
| "log" | ||
| "os" | ||
| "sync" | ||
| "sync/atomic" | ||
| "time" | ||
|
|
||
| "github.com/1Panel-dev/1Panel/core/constant" | ||
| "github.com/gin-gonic/gin" | ||
| "github.com/glebarez/sqlite" | ||
| "github.com/gorilla/securecookie" | ||
| "github.com/gorilla/sessions" | ||
| "github.com/wader/gormstore/v2" | ||
| "gorm.io/gorm" | ||
| "gorm.io/gorm/logger" | ||
| ) | ||
|
|
||
| type SessionUser struct { | ||
| ID uint `json:"id"` | ||
| Name string `json:"name"` | ||
| } | ||
|
|
||
| type sessionItem struct { | ||
| User SessionUser | ||
| ExpiredAt time.Time | ||
| } | ||
|
|
||
| type PSession struct { | ||
| Store *gormstore.Store | ||
| db *gorm.DB | ||
| } | ||
|
|
||
| func NewPSession(dbPath string) *PSession { | ||
| newLogger := logger.New( | ||
| log.New(os.Stdout, "\r\n", log.LstdFlags), | ||
| logger.Config{ | ||
| SlowThreshold: time.Second, | ||
| LogLevel: logger.Silent, | ||
| IgnoreRecordNotFoundError: true, | ||
| Colorful: false, | ||
| }, | ||
| ) | ||
| db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ | ||
| DisableForeignKeyConstraintWhenMigrating: true, | ||
| Logger: newLogger, | ||
| }) | ||
| if err != nil { | ||
| panic(err) | ||
| } | ||
| sqlDB, dbError := db.DB() | ||
| if dbError != nil { | ||
| panic(dbError) | ||
| } | ||
| sqlDB.SetMaxOpenConns(4) | ||
| sqlDB.SetMaxIdleConns(1) | ||
| sqlDB.SetConnMaxIdleTime(15 * time.Minute) | ||
| sqlDB.SetConnMaxLifetime(time.Hour) | ||
|
|
||
| store := gormstore.New(db, securecookie.GenerateRandomKey(32)) | ||
| mu sync.RWMutex | ||
| sessions map[string]sessionItem | ||
| cleanupCursor uint64 | ||
| lastFullCleanup time.Time | ||
| } | ||
|
|
||
| func NewPSession(_ string) *PSession { | ||
| return &PSession{ | ||
| Store: store, | ||
| db: db, | ||
| sessions: make(map[string]sessionItem), | ||
| } | ||
| } | ||
|
|
||
| func (p *PSession) Get(c *gin.Context) (SessionUser, error) { | ||
| var result SessionUser | ||
| session, err := p.Store.Get(c.Request, constant.SessionName) | ||
| if err != nil { | ||
| return result, err | ||
|
|
||
| sessionID, err := c.Cookie(constant.SessionName) | ||
| if err != nil || sessionID == "" { | ||
| return result, errors.New("ErrSessionDataNotFound") | ||
| } | ||
| data, ok := session.Values["user"] | ||
|
|
||
| p.mu.RLock() | ||
| item, ok := p.sessions[sessionID] | ||
| p.mu.RUnlock() | ||
| if !ok { | ||
| return result, errors.New("ErrSessionDataNotFound") | ||
| } | ||
| bytes, ok := data.([]byte) | ||
| if !ok { | ||
| return result, errors.New("ErrSessionDataFormat") | ||
| if !item.ExpiredAt.IsZero() && time.Now().After(item.ExpiredAt) { | ||
| p.mu.Lock() | ||
| delete(p.sessions, sessionID) | ||
| p.mu.Unlock() | ||
| return result, errors.New("ErrSessionDataNotFound") | ||
| } | ||
| err = json.Unmarshal(bytes, &result) | ||
| return result, err | ||
| return item.User, nil | ||
| } | ||
|
|
||
| func (p *PSession) Set(c *gin.Context, user SessionUser, secure bool, ttlSeconds int) error { | ||
| session, err := p.Store.Get(c.Request, constant.SessionName) | ||
| if err != nil { | ||
| return err | ||
| return p.set(c, user, secure, ttlSeconds, false) | ||
| } | ||
|
|
||
| func (p *PSession) SetFresh(c *gin.Context, user SessionUser, secure bool, ttlSeconds int) error { | ||
| return p.set(c, user, secure, ttlSeconds, true) | ||
| } | ||
|
|
||
| func (p *PSession) set(c *gin.Context, user SessionUser, secure bool, ttlSeconds int, forceNew bool) error { | ||
| sessionID, err := c.Cookie(constant.SessionName) | ||
| if forceNew { | ||
| if err == nil && sessionID != "" { | ||
| p.mu.Lock() | ||
| delete(p.sessions, sessionID) | ||
| p.mu.Unlock() | ||
| } | ||
| sessionID = "" | ||
| } | ||
| data, err := json.Marshal(user) | ||
| if err != nil { | ||
| return err | ||
| if err != nil || sessionID == "" { | ||
| sessionID, err = generateSessionID() | ||
| if err != nil { | ||
| return err | ||
| } | ||
| } | ||
| session.Values["user"] = data | ||
| session.Options = &sessions.Options{ | ||
| Path: "/", | ||
| MaxAge: ttlSeconds, | ||
| HttpOnly: true, | ||
| Secure: secure, | ||
|
|
||
| expiredAt := time.Now().Add(time.Duration(ttlSeconds) * time.Second) | ||
| p.mu.Lock() | ||
| p.sessions[sessionID] = sessionItem{ | ||
| User: user, | ||
| ExpiredAt: expiredAt, | ||
| } | ||
| return p.Store.Save(c.Request, c.Writer, session) | ||
| p.mu.Unlock() | ||
| p.cleanupExpiredOnWrite() | ||
|
|
||
| c.SetCookie(constant.SessionName, sessionID, ttlSeconds, "/", "", secure, true) | ||
| return nil | ||
| } | ||
|
|
||
| func (p *PSession) Delete(c *gin.Context) error { | ||
| session, err := p.Store.Get(c.Request, constant.SessionName) | ||
| if err != nil { | ||
| return err | ||
| func (p *PSession) RefreshIfNeeded(c *gin.Context, user SessionUser, secure bool, ttlSeconds int) (bool, error) { | ||
| sessionID, err := c.Cookie(constant.SessionName) | ||
| if err != nil || sessionID == "" { | ||
| return false, p.Set(c, user, secure, ttlSeconds) | ||
| } | ||
|
|
||
| now := time.Now() | ||
| window := refreshWindow(ttlSeconds) | ||
|
|
||
| p.mu.RLock() | ||
| item, ok := p.sessions[sessionID] | ||
| p.mu.RUnlock() | ||
| if !ok { | ||
| return false, p.Set(c, user, secure, ttlSeconds) | ||
| } | ||
| if !item.ExpiredAt.IsZero() && now.After(item.ExpiredAt) { | ||
| p.mu.Lock() | ||
| delete(p.sessions, sessionID) | ||
| p.mu.Unlock() | ||
| return false, errors.New("ErrSessionDataNotFound") | ||
| } | ||
| if item.ExpiredAt.Sub(now) > window { | ||
| return false, nil | ||
| } | ||
| return true, p.Set(c, user, secure, ttlSeconds) | ||
| } | ||
|
|
||
| session.Values = make(map[interface{}]interface{}) | ||
| session.Options.MaxAge = -1 | ||
| return p.Store.Save(c.Request, c.Writer, session) | ||
| func (p *PSession) Delete(c *gin.Context) error { | ||
| sessionID, err := c.Cookie(constant.SessionName) | ||
| if err == nil && sessionID != "" { | ||
| p.mu.Lock() | ||
| delete(p.sessions, sessionID) | ||
| p.mu.Unlock() | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func (p *PSession) Clean() error { | ||
| p.db.Table("sessions").Where("1=1").Delete(nil) | ||
| p.mu.Lock() | ||
| p.sessions = make(map[string]sessionItem) | ||
| p.lastFullCleanup = time.Time{} | ||
| p.mu.Unlock() | ||
| return nil | ||
| } | ||
|
|
||
| func generateSessionID() (string, error) { | ||
| buf := make([]byte, 32) | ||
| if _, err := rand.Read(buf); err != nil { | ||
| return "", err | ||
| } | ||
| return hex.EncodeToString(buf), nil | ||
| } | ||
|
|
||
| func refreshWindow(ttlSeconds int) time.Duration { | ||
| if ttlSeconds <= 0 { | ||
| return 0 | ||
| } | ||
| windowSeconds := ttlSeconds / 10 | ||
| if windowSeconds < 60 { | ||
| windowSeconds = 60 | ||
| } | ||
| if windowSeconds > 300 { | ||
| windowSeconds = 300 | ||
| } | ||
|
Comment on lines
+160
to
+162
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| if windowSeconds >= ttlSeconds { | ||
| windowSeconds = ttlSeconds - 1 | ||
| } | ||
| if windowSeconds <= 0 { | ||
| windowSeconds = 1 | ||
| } | ||
| return time.Duration(windowSeconds) * time.Second | ||
| } | ||
|
|
||
| func (p *PSession) cleanupExpiredOnWrite() { | ||
| const ( | ||
| sampleSize = 32 | ||
| fullCleanupThreshold = 1024 | ||
| fullCleanupMinInterval = time.Minute | ||
| ) | ||
|
|
||
| now := time.Now() | ||
|
|
||
| p.mu.RLock() | ||
| size := len(p.sessions) | ||
| lastFullCleanup := p.lastFullCleanup | ||
| p.mu.RUnlock() | ||
|
|
||
| if size == 0 { | ||
| return | ||
| } | ||
| if size >= fullCleanupThreshold && now.Sub(lastFullCleanup) >= fullCleanupMinInterval { | ||
| p.cleanupExpiredAll(now) | ||
| return | ||
| } | ||
| p.cleanupExpiredSample(now, sampleSize) | ||
| } | ||
|
|
||
| func (p *PSession) cleanupExpiredSample(now time.Time, limit int) { | ||
| if limit <= 0 { | ||
| return | ||
| } | ||
|
|
||
| p.mu.Lock() | ||
| defer p.mu.Unlock() | ||
|
|
||
| total := len(p.sessions) | ||
| if total == 0 { | ||
| return | ||
| } | ||
| start := int(atomic.AddUint64(&p.cleanupCursor, uint64(limit)) % uint64(total)) | ||
| checked := 0 | ||
|
|
||
| idx := 0 | ||
| for key, item := range p.sessions { | ||
| if idx < start { | ||
| idx++ | ||
| continue | ||
| } | ||
| if !item.ExpiredAt.IsZero() && now.After(item.ExpiredAt) { | ||
| delete(p.sessions, key) | ||
| } | ||
| checked++ | ||
| idx++ | ||
| if checked >= limit { | ||
| break | ||
| } | ||
| } | ||
| if checked < limit { | ||
| for key, item := range p.sessions { | ||
| if checked >= limit { | ||
| break | ||
| } | ||
| if !item.ExpiredAt.IsZero() && now.After(item.ExpiredAt) { | ||
| delete(p.sessions, key) | ||
| } | ||
| checked++ | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func (p *PSession) cleanupExpiredAll(now time.Time) { | ||
| p.mu.Lock() | ||
| for key, item := range p.sessions { | ||
| if !item.ExpiredAt.IsZero() && now.After(item.ExpiredAt) { | ||
| delete(p.sessions, key) | ||
| } | ||
| } | ||
| p.lastFullCleanup = now | ||
| p.mu.Unlock() | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,11 @@ | ||
| package session | ||
|
|
||
| import ( | ||
| "path" | ||
|
|
||
| "github.com/1Panel-dev/1Panel/core/global" | ||
| "github.com/1Panel-dev/1Panel/core/init/session/psession" | ||
| ) | ||
|
|
||
| func Init() { | ||
| global.SESSION = psession.NewPSession(path.Join(global.CONF.Base.InstallDir, "1panel/db/session.db")) | ||
| global.LOG.Info("init session successfully") | ||
| global.SESSION = psession.NewPSession("") | ||
| global.LOG.Info("init in-memory session successfully") | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new in-memory store inserts a session entry on every new login/session ID without any global size limit or eviction policy. Because entries live for
ttlSeconds(default is long) and login creates fresh IDs, repeated successful logins (e.g., automated clients not reusing cookies) can growp.sessionsunbounded and drive memory pressure/OOM in the API process.Useful? React with 👍 / 👎.