diff --git a/agent/app/api/v2/terminal.go b/agent/app/api/v2/terminal.go index 4c25caccc..9e55c936e 100644 --- a/agent/app/api/v2/terminal.go +++ b/agent/app/api/v2/terminal.go @@ -1,7 +1,9 @@ package v2 import ( + "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "net/http" @@ -32,7 +34,7 @@ import ( // @Security Timestamp // @Router /hosts/terminal/local [get] func (b *BaseApi) WsLocalTerminal(c *gin.Context) { - b.runSSHSession(c, loadLocalConn, c.DefaultQuery("command", "")) + b.runSSHSession(c, "local", loadLocalConn, c.DefaultQuery("command", "")) } // @Tags Terminal @@ -46,7 +48,7 @@ func (b *BaseApi) WsLocalTerminal(c *gin.Context) { // @Security Timestamp // @Router /hosts/terminal/ssh [get] func (b *BaseApi) WsHostSSH(c *gin.Context) { - b.runSSHSession(c, func() (*ssh.SSHClient, error) { + b.runSSHSession(c, "ssh", func() (*ssh.SSHClient, error) { hostID, _ := strconv.Atoi(c.DefaultQuery("id", "0")) if hostID <= 0 { return nil, errors.New("missing host id") @@ -70,26 +72,33 @@ func (b *BaseApi) WsContainerTerminal(c *gin.Context) { return } defer wsConn.Close() - - slave, err := loadContainerTerminalCommand(c) - if wshandleError(wsConn, err) { - return - } - defer slave.Close() - - tty, err := terminal.NewLocalWsSession(cols, rows, wsConn, slave, false) - if wshandleError(wsConn, err) { + identity, ok := loadTerminalIdentity(c) + if !ok { + _ = wshandleError(wsConn, errors.New("missing terminal identity")) return } - quitChan := make(chan bool, 3) - tty.Start(quitChan) - go slave.Wait(quitChan) + opts := terminal.SessionOptions{ + Identity: identity, + Kind: "container", + Target: containerTerminalTarget(c), + Cols: cols, + Rows: rows, + } + if err := terminal.ServeCommand(wsConn, strings.TrimSpace(c.Query("session")), opts, func() (*terminal.LocalCommand, error) { + return loadContainerTerminalCommand(c) + }); err != nil { + _ = wshandleError(wsConn, err) + } +} - <-quitChan - - global.LOG.Info("websocket finished") - closeTerminalConn(wsConn) +func containerTerminalTarget(c *gin.Context) string { + query := c.Request.URL.Query() + for _, key := range []string{"cols", "rows", "session", "terminalRevalidate"} { + query.Del(key) + } + sum := sha256.Sum256([]byte(query.Encode())) + return hex.EncodeToString(sum[:]) } func prepareTerminalSession(c *gin.Context) (*websocket.Conn, int, int, bool) { @@ -120,21 +129,30 @@ func prepareTerminalSession(c *gin.Context) (*websocket.Conn, int, int, bool) { return wsConn, cols, rows, true } -func (b *BaseApi) runSSHSession(c *gin.Context, connect func() (*ssh.SSHClient, error), command string) { +func (b *BaseApi) runSSHSession(c *gin.Context, kind string, connect func() (*ssh.SSHClient, error), command string) { wsConn, cols, rows, ok := prepareTerminalSession(c) if !ok { return } defer wsConn.Close() + identity, ok := loadTerminalIdentity(c) + if !ok { + _ = wshandleError(wsConn, errors.New("missing terminal identity")) + return + } - hostID, _ := strconv.Atoi(c.DefaultQuery("id", "0")) + hostID := 0 + if kind == "ssh" { + hostID, _ = strconv.Atoi(c.DefaultQuery("id", "0")) + } opts := terminal.SessionOptions{ - Owner: loadAuditUser(c), - Title: sanitizeTerminalTitle(c.Query("title")), - HostID: uint(max(hostID, 0)), - Cols: cols, - Rows: rows, - InitCmd: command, + Identity: identity, + Kind: kind, + Title: sanitizeTerminalTitle(c.Query("title")), + HostID: uint(max(hostID, 0)), + Cols: cols, + Rows: rows, + InitCmd: command, } err := terminal.Serve(wsConn, strings.TrimSpace(c.Query("session")), opts, func() (*gossh.Client, error) { client, err := connect() @@ -155,7 +173,12 @@ func (b *BaseApi) runSSHSession(c *gin.Context, connect func() (*ssh.SSHClient, // @Security Timestamp // @Router /hosts/terminal/sessions/search [post] func (b *BaseApi) SearchTerminalSessions(c *gin.Context) { - helper.SuccessWithData(c, terminal.List(loadAuditUser(c))) + identity, ok := loadTerminalIdentity(c) + if !ok { + helper.BadRequest(c, errors.New("missing terminal identity")) + return + } + helper.SuccessWithData(c, terminal.List(identity)) } // @Tags Terminal @@ -171,7 +194,12 @@ func (b *BaseApi) CloseTerminalSession(c *gin.Context) { if err := helper.CheckBindAndValidate(&req, c); err != nil { return } - if err := terminal.CloseSession(req.ID, loadAuditUser(c)); err != nil { + identity, ok := loadTerminalIdentity(c) + if !ok { + helper.BadRequest(c, errors.New("missing terminal identity")) + return + } + if err := terminal.CloseSession(req.ID, identity); err != nil { helper.BadRequest(c, err) return } @@ -179,16 +207,42 @@ func (b *BaseApi) CloseTerminalSession(c *gin.Context) { } // @Tags Terminal -// @Summary Close every terminal session (panel user logged out) // @Success 200 // @Security ApiKeyAuth // @Security Timestamp // @Router /hosts/terminal/sessions/closeAll [post] func (b *BaseApi) CloseAllTerminalSessions(c *gin.Context) { - terminal.CloseAll() + identity, ok := loadTerminalIdentity(c) + if !ok { + helper.BadRequest(c, errors.New("missing terminal identity")) + return + } + terminal.Revoke("auth_session", identity.UserID, identity.AuthSessionID) helper.Success(c) } +func (b *BaseApi) RevokeTerminalSessions(c *gin.Context) { + var req dto.TerminalSessionRevoke + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if (req.Scope == "auth_session" && (req.UserID == "" || req.AuthSessionID == "")) || + (req.Scope == "user" && req.UserID == "") { + helper.BadRequest(c, errors.New("missing terminal revocation identity")) + return + } + terminal.Revoke(req.Scope, req.UserID, req.AuthSessionID) + helper.Success(c) +} + +func loadTerminalIdentity(c *gin.Context) (terminal.Identity, bool) { + identity := terminal.Identity{ + UserID: strings.TrimSpace(c.GetHeader(terminal.HeaderUserID)), + AuthSessionID: strings.TrimSpace(c.GetHeader(terminal.HeaderAuthSessionID)), + } + return identity, identity.Valid() +} + // sanitizeTerminalTitle keeps the title a short single line. func sanitizeTerminalTitle(title string) string { title = strings.Join(strings.Fields(title), " ") diff --git a/agent/app/dto/terminal.go b/agent/app/dto/terminal.go index 017c55eb8..2b5a822a2 100644 --- a/agent/app/dto/terminal.go +++ b/agent/app/dto/terminal.go @@ -3,3 +3,9 @@ package dto type TerminalSessionClose struct { ID string `json:"id" validate:"required"` } + +type TerminalSessionRevoke struct { + Scope string `json:"scope" validate:"required,oneof=auth_session user all"` + UserID string `json:"userId"` + AuthSessionID string `json:"authSessionId"` +} diff --git a/agent/router/ro_host.go b/agent/router/ro_host.go index b747dbbb4..f66ce4c83 100644 --- a/agent/router/ro_host.go +++ b/agent/router/ro_host.go @@ -10,6 +10,7 @@ type HostRouter struct{} func (s *HostRouter) InitRouter(Router *gin.RouterGroup) { hostRouter := Router.Group("hosts") baseApi := v2.ApiGroupApp.BaseApi + Router.POST("/internal/terminal/sessions/revoke", baseApi.RevokeTerminalSessions) { hostRouter.POST("", baseApi.CreateHost) hostRouter.POST("/info", baseApi.GetHostByID) diff --git a/agent/utils/terminal/attachment.go b/agent/utils/terminal/attachment.go index 3b2f764ac..132405c8b 100644 --- a/agent/utils/terminal/attachment.go +++ b/agent/utils/terminal/attachment.go @@ -6,6 +6,7 @@ import ( "errors" "strings" "sync" + "sync/atomic" "time" "github.com/1Panel-dev/1Panel/agent/global" @@ -31,8 +32,9 @@ type attachment struct { writeMu sync.Mutex cursor uint64 // ring offset of the next byte to send; guarded by writeMu - done chan struct{} - closeOnce sync.Once + done chan struct{} + closeOnce sync.Once + revalidateRequested atomic.Bool } // Run reads client messages until the websocket fails or this attachment is closed. @@ -44,7 +46,7 @@ func (a *attachment) Run() { global.LOG.Errorf("[A panic occurred during receive ws message, error message: %v", r) } a.close(websocket.CloseNormalClosure, "") - a.sess.detach(a, clean) + a.sess.detach(a, clean, a.revalidateRequested.Load(), a.cursorOffset()) }() _ = a.ws.SetReadDeadline(time.Now().Add(pongWait)) @@ -52,6 +54,7 @@ func (a *attachment) Run() { return a.ws.SetReadDeadline(time.Now().Add(pongWait)) }) go a.pingLoop() + go a.revalidateLoop() // close() shuts the websocket, which is what ends this loop. for { @@ -96,6 +99,25 @@ func (a *attachment) Run() { } } +func (a *attachment) cursorOffset() uint64 { + a.writeMu.Lock() + defer a.writeMu.Unlock() + return a.cursor +} + +func (a *attachment) revalidateLoop() { + timer := time.NewTimer(revalidateInterval) + defer timer.Stop() + select { + case <-a.done: + return + case <-timer.C: + a.revalidateRequested.Store(true) + a.sess.markRevalidation(a, a.cursorOffset()) + a.close(CloseCodeRevalidate, "terminal authorization revalidation required") + } +} + // pingLoop keeps the read deadline honest; a ping that cannot be sent ends the attachment. func (a *attachment) pingLoop() { tick := time.NewTicker(pingInterval) diff --git a/agent/utils/terminal/command_backend.go b/agent/utils/terminal/command_backend.go new file mode 100644 index 000000000..4a5033871 --- /dev/null +++ b/agent/utils/terminal/command_backend.go @@ -0,0 +1,46 @@ +package terminal + +import ( + "io" + "syscall" + "time" +) + +type commandBackend struct { + command *LocalCommand + readDone chan struct{} +} + +func newCommandBackend(command *LocalCommand, out io.Writer) *commandBackend { + b := &commandBackend{command: command, readDone: make(chan struct{})} + go func() { + defer close(b.readDone) + _, _ = io.Copy(out, command) + }() + return b +} + +func (b *commandBackend) Write(p []byte) (int, error) { + return b.command.Write(p) +} + +func (b *commandBackend) Resize(cols, rows int) error { + return b.command.ResizeTerminal(cols, rows) +} + +func (b *commandBackend) Wait() error { + err := b.command.WaitResult() + select { + case <-b.readDone: + case <-time.After(time.Second): + } + return err +} + +func (b *commandBackend) Keepalive() error { + return b.command.Signal(syscall.Signal(0)) +} + +func (b *commandBackend) Close() error { + return b.command.Close() +} diff --git a/agent/utils/terminal/local_cmd.go b/agent/utils/terminal/local_cmd.go index aecd3e389..7978d34c3 100644 --- a/agent/utils/terminal/local_cmd.go +++ b/agent/utils/terminal/local_cmd.go @@ -3,6 +3,7 @@ package terminal import ( "os" "os/exec" + "sync" "syscall" "time" "unsafe" @@ -23,6 +24,9 @@ type LocalCommand struct { cmd *exec.Cmd pty *os.File + + closeOnce sync.Once + closeErr error } func NewCommand(name string, arg ...string) (*LocalCommand, error) { @@ -60,26 +64,36 @@ func (lcmd *LocalCommand) Write(p []byte) (n int, err error) { } func (lcmd *LocalCommand) Close() error { - if lcmd.pty != nil { - lcmd.pty.Write([]byte{3}) - time.Sleep(50 * time.Millisecond) - - lcmd.pty.Write([]byte{4}) - time.Sleep(50 * time.Millisecond) - - lcmd.pty.Write([]byte("exit\n")) - time.Sleep(50 * time.Millisecond) - } - if lcmd.cmd != nil && lcmd.cmd.Process != nil { - lcmd.cmd.Process.Signal(syscall.SIGTERM) - time.Sleep(50 * time.Millisecond) - - if lcmd.cmd.ProcessState == nil || !lcmd.cmd.ProcessState.Exited() { - lcmd.cmd.Process.Kill() + lcmd.closeOnce.Do(func() { + if lcmd.pty != nil { + _, _ = lcmd.pty.Write([]byte{3}) + time.Sleep(50 * time.Millisecond) + _, _ = lcmd.pty.Write([]byte{4}) + time.Sleep(50 * time.Millisecond) + _, _ = lcmd.pty.Write([]byte("exit\n")) + time.Sleep(50 * time.Millisecond) } + if lcmd.cmd != nil && lcmd.cmd.Process != nil { + _ = lcmd.cmd.Process.Signal(syscall.SIGTERM) + time.Sleep(50 * time.Millisecond) + _ = lcmd.cmd.Process.Kill() + } + if lcmd.pty != nil { + lcmd.closeErr = lcmd.pty.Close() + } + }) + return lcmd.closeErr +} + +func (lcmd *LocalCommand) WaitResult() error { + return lcmd.cmd.Wait() +} + +func (lcmd *LocalCommand) Signal(signal os.Signal) error { + if lcmd.cmd == nil || lcmd.cmd.Process == nil { + return os.ErrProcessDone } - _ = lcmd.pty.Close() - return nil + return lcmd.cmd.Process.Signal(signal) } func (lcmd *LocalCommand) ResizeTerminal(width int, height int) error { @@ -108,7 +122,7 @@ func (lcmd *LocalCommand) ResizeTerminal(width int, height int) error { } func (lcmd *LocalCommand) Wait(quitChan chan bool) { - if err := lcmd.cmd.Wait(); err != nil { + if err := lcmd.WaitResult(); err != nil { global.LOG.Errorf("ssh session wait failed, err: %v", err) setQuit(quitChan) } diff --git a/agent/utils/terminal/registry.go b/agent/utils/terminal/registry.go index 8ce8755dc..f3103bf28 100644 --- a/agent/utils/terminal/registry.go +++ b/agent/utils/terminal/registry.go @@ -2,6 +2,7 @@ package terminal import ( "errors" + "fmt" "sort" "sync" ) @@ -10,27 +11,95 @@ import ( // Open stores, Close deletes. var sessions sync.Map +const maxSessionsPerIdentity = 10 + +var ( + sessionSlotsMu sync.Mutex + sessionSlots = make(map[Identity]int) +) + var errSessionNotFound = errors.New("terminal session not found") -// Lookup resolves id for owner. Foreign and unknown sessions look the same. -func Lookup(id, owner string) (*Session, bool) { +const ( + HeaderUserID = "X-Panel-User-ID" + HeaderAuthSessionID = "X-Panel-Auth-Session-ID" +) + +type Identity struct { + UserID string + AuthSessionID string +} + +func (i Identity) Valid() bool { + return i.UserID != "" && i.AuthSessionID != "" +} + +func reserveSessionSlot(identity Identity) error { + if !identity.Valid() { + return errors.New("missing terminal identity") + } + sessionSlotsMu.Lock() + defer sessionSlotsMu.Unlock() + if sessionSlots[identity] >= maxSessionsPerIdentity { + return fmt.Errorf("terminal session limit reached (maximum %d)", maxSessionsPerIdentity) + } + sessionSlots[identity]++ + return nil +} + +func releaseSessionSlot(identity Identity) { + sessionSlotsMu.Lock() + defer sessionSlotsMu.Unlock() + releaseSessionSlotLocked(identity) +} + +func releaseSessionSlotLocked(identity Identity) { + remaining := sessionSlots[identity] - 1 + if remaining <= 0 { + delete(sessionSlots, identity) + return + } + sessionSlots[identity] = remaining +} + +func registerReservedSession(s *Session) { + sessions.Store(s.ID, s) +} + +func unregisterSession(s *Session) { + sessionSlotsMu.Lock() + defer sessionSlotsMu.Unlock() + current, ok := sessions.Load(s.ID) + if !ok || current != s { + return + } + sessions.Delete(s.ID) + releaseSessionSlotLocked(Identity{UserID: s.UserID, AuthSessionID: s.AuthSessionID}) +} + +func Lookup(id string, identity Identity) (*Session, bool) { + if !identity.Valid() { + return nil, false + } v, ok := sessions.Load(id) if !ok { return nil, false } s := v.(*Session) - if s.Owner != "" && owner != "" && s.Owner != owner { + if s.UserID != identity.UserID || s.AuthSessionID != identity.AuthSessionID { return nil, false } return s, true } -// List returns owner's sessions, oldest first. -func List(owner string) []Info { +func List(identity Identity) []Info { + if !identity.Valid() { + return nil + } var out []Info sessions.Range(func(_, v any) bool { s := v.(*Session) - if s.Owner == "" || owner == "" || s.Owner == owner { + if s.UserID == identity.UserID && s.AuthSessionID == identity.AuthSessionID { out = append(out, s.Info()) } return true @@ -39,20 +108,33 @@ func List(owner string) []Info { return out } -// CloseAll ends every live session; core calls it when the panel user logs out. -func CloseAll() { - sessions.Range(func(_, v any) bool { - v.(*Session).Close() - return true - }) -} - -// CloseSession closes id on behalf of owner. -func CloseSession(id, owner string) error { - s, ok := Lookup(id, owner) +func CloseSession(id string, identity Identity) error { + s, ok := Lookup(id, identity) if !ok { return errSessionNotFound } s.Close() return nil } + +func Revoke(scope, userID, authSessionID string) int { + closed := 0 + sessions.Range(func(_, v any) bool { + s := v.(*Session) + match := false + switch scope { + case "auth_session": + match = userID != "" && authSessionID != "" && s.UserID == userID && s.AuthSessionID == authSessionID + case "user": + match = userID != "" && s.UserID == userID + case "all": + match = true + } + if match { + closed++ + s.Close() + } + return true + }) + return closed +} diff --git a/agent/utils/terminal/session.go b/agent/utils/terminal/session.go index c82a9995f..8e8bf532f 100644 --- a/agent/utils/terminal/session.go +++ b/agent/utils/terminal/session.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "io" "sync" "time" @@ -22,10 +23,12 @@ import ( // // ponytail: all fixed; promote to settings only if someone asks. const ( - graceTimeout = 30 * time.Minute - keepaliveInterval = 30 * time.Second - keepaliveTimeout = 10 * time.Second - pumpInterval = 60 * time.Millisecond + graceTimeout = 30 * time.Minute + revalidateInterval = 60 * time.Second + revalidateGrace = 30 * time.Second + keepaliveInterval = 30 * time.Second + keepaliveTimeout = 10 * time.Second + pumpInterval = 60 * time.Millisecond ) // Websocket close codes of the session protocol; the frontend switches on them. @@ -34,23 +37,27 @@ const ( CloseCodeSessionNotFound = 4404 // CloseCodeAttachedElsewhere: a newer websocket took over the session. CloseCodeAttachedElsewhere = 4409 + CloseCodeRevalidate = 4410 ) var errSessionClosed = errors.New("terminal session is closed") // SessionOptions describes a session that is about to be created. type SessionOptions struct { - Owner string - Title string - HostID uint // 0 = local shell - Cols int - Rows int - InitCmd string + Identity Identity + Kind string + Target string + Title string + HostID uint // 0 = local shell + Cols int + Rows int + InitCmd string } // Info is the client visible snapshot of a session. type Info struct { ID string `json:"id"` + Kind string `json:"kind"` Title string `json:"title"` HostID uint `json:"hostId"` Attached bool `json:"attached"` @@ -60,20 +67,25 @@ type Info struct { // Session owns one shell; a websocket is only a detachable attachment. type Session struct { - ID string - Owner string - Title string - HostID uint - CreatedAt time.Time + ID string + UserID string + AuthSessionID string + Kind string + Target string + Title string + HostID uint + CreatedAt time.Time - mu sync.Mutex - attached *attachment - detachedAt time.Time - grace *time.Timer - cols int - rows int + mu sync.Mutex + attached *attachment + detachedAt time.Time + grace *time.Timer + revalidateCursor uint64 + revalidatePending bool + cols int + rows int - backend *sshBackend + backend sessionBackend ring *ringBuffer lang string @@ -84,13 +96,49 @@ type Session struct { closeFn func() } +type sessionBackend interface { + io.Writer + Resize(cols, rows int) error + Wait() error + Keepalive() error + Close() error +} + // Serve drives ws until it ends: it reattaches to sessionID when given, and // otherwise opens a fresh shell on the client that connect returns. A returned // error has not been reported to the client yet. func Serve(ws *websocket.Conn, sessionID string, opts SessionOptions, connect func() (*gossh.Client, error)) error { + return serve(ws, sessionID, opts, func() (*Session, error) { + client, err := connect() + if err != nil { + return nil, err + } + sess, err := Open(client, opts) + if err != nil { + _ = client.Close() + } + return sess, err + }) +} + +func ServeCommand(ws *websocket.Conn, sessionID string, opts SessionOptions, connect func() (*LocalCommand, error)) error { + return serve(ws, sessionID, opts, func() (*Session, error) { + command, err := connect() + if err != nil { + return nil, err + } + sess, err := OpenCommand(command, opts) + if err != nil { + _ = command.Close() + } + return sess, err + }) +} + +func serve(ws *websocket.Conn, sessionID string, opts SessionOptions, open func() (*Session, error)) error { if sessionID != "" { - sess, ok := Lookup(sessionID, opts.Owner) - if ok { + sess, ok := Lookup(sessionID, opts.Identity) + if ok && sess.Kind == opts.Kind && sess.Target == opts.Target && sess.HostID == opts.HostID { att, err := sess.Attach(ws, opts.Cols, opts.Rows) if err == nil { att.Run() @@ -102,15 +150,10 @@ func Serve(ws *websocket.Conn, sessionID string, opts SessionOptions, connect fu return nil } - client, err := connect() + sess, err := open() if err != nil { return err } - sess, err := Open(client, opts) - if err != nil { - _ = client.Close() - return err - } // no sess.Close() on return: a dirty disconnect leaves the shell alive for a reattach att, err := sess.Attach(ws, opts.Cols, opts.Rows) if err != nil { @@ -123,15 +166,43 @@ func Serve(ws *websocket.Conn, sessionID string, opts SessionOptions, connect fu // Open starts a shell on client and registers the session. func Open(client *gossh.Client, opts SessionOptions) (*Session, error) { + if err := validateSessionOptions(opts); err != nil { + return nil, err + } + if err := reserveSessionSlot(opts.Identity); err != nil { + return nil, err + } ring := newRingBuffer() backend, err := newSSHBackend(client, opts.Cols, opts.Rows, opts.InitCmd, ring) if err != nil { + releaseSessionSlot(opts.Identity) return nil, err } + return openBackend(backend, ring, opts), nil +} + +func OpenCommand(command *LocalCommand, opts SessionOptions) (*Session, error) { + if err := validateSessionOptions(opts); err != nil { + return nil, err + } + if command == nil { + return nil, errors.New("nil terminal command") + } + if err := reserveSessionSlot(opts.Identity); err != nil { + return nil, err + } + ring := newRingBuffer() + return openBackend(newCommandBackend(command, ring), ring, opts), nil +} + +func openBackend(backend sessionBackend, ring *ringBuffer, opts SessionOptions) *Session { lang := i18n.GetLanguageFromDB() s := &Session{ ID: uuid.NewString(), - Owner: opts.Owner, + UserID: opts.Identity.UserID, + AuthSessionID: opts.Identity.AuthSessionID, + Kind: opts.Kind, + Target: opts.Target, Title: opts.Title, HostID: opts.HostID, CreatedAt: time.Now(), @@ -145,11 +216,24 @@ func Open(client *gossh.Client, opts SessionOptions) (*Session, error) { done: make(chan struct{}), } s.closeFn = sync.OnceFunc(s.doClose) - sessions.Store(s.ID, s) + registerReservedSession(s) go s.pump() go s.keepaliveLoop() go s.waitBackend() - return s, nil + return s +} + +func validateSessionOptions(opts SessionOptions) error { + if !opts.Identity.Valid() { + return errors.New("missing terminal identity") + } + if opts.Kind != "local" && opts.Kind != "ssh" && opts.Kind != "container" { + return errors.New("invalid terminal kind") + } + if opts.Kind == "container" && opts.Target == "" { + return errors.New("missing container terminal target") + } + return nil } // Attach binds ws to the session, kicking any previous attachment, and replays @@ -182,6 +266,10 @@ func (s *Session) Attach(ws *websocket.Conn, cols, rows int) (*attachment, error } cols, rows = s.cols, s.rows att.cursor = s.ring.Oldest() + if s.revalidatePending { + att.cursor = s.revalidateCursor + } + s.revalidatePending = false // Hold writeMu across unlock so hello+replay go out before the pump can write. att.writeMu.Lock() s.mu.Unlock() @@ -208,7 +296,7 @@ func (s *Session) Attach(ws *websocket.Conn, cols, rows int) (*attachment, error }() if err != nil { att.close(websocket.CloseInternalServerErr, "attach failed") - s.detach(att, false) + s.detach(att, false, false, 0) return nil, err } @@ -219,7 +307,7 @@ func (s *Session) Attach(ws *websocket.Conn, cols, rows int) (*attachment, error } // detach unbinds a. A clean detach closes the shell; a dirty one arms the grace timer. -func (s *Session) detach(a *attachment, clean bool) { +func (s *Session) detach(a *attachment, clean, revalidate bool, cursor uint64) { s.mu.Lock() if s.attached != a { s.mu.Unlock() @@ -227,8 +315,16 @@ func (s *Session) detach(a *attachment, clean bool) { } s.attached = nil s.detachedAt = time.Now() + s.revalidatePending = revalidate + if revalidate { + s.revalidateCursor = cursor + } if !clean { - s.grace = time.AfterFunc(graceTimeout, s.Close) + timeout := graceTimeout + if revalidate { + timeout = revalidateGrace + } + s.grace = time.AfterFunc(timeout, s.Close) } s.mu.Unlock() global.LOG.Debugf("terminal session %s detached, clean=%v", s.ID, clean) @@ -237,6 +333,16 @@ func (s *Session) detach(a *attachment, clean bool) { } } +func (s *Session) markRevalidation(a *attachment, cursor uint64) { + s.mu.Lock() + defer s.mu.Unlock() + if s.attached != a { + return + } + s.revalidatePending = true + s.revalidateCursor = cursor +} + // Close terminates the shell and any attachment. Idempotent. func (s *Session) Close() { s.closeFn() } @@ -252,10 +358,10 @@ func (s *Session) doClose() { if att != nil { att.close(websocket.CloseNormalClosure, "") } + unregisterSession(s) if err := s.backend.Close(); err != nil { global.LOG.Debugf("close terminal backend: %v", err) } - sessions.Delete(s.ID) } // Info snapshots the session for listing. @@ -264,6 +370,7 @@ func (s *Session) Info() Info { defer s.mu.Unlock() return Info{ ID: s.ID, + Kind: s.Kind, Title: s.Title, HostID: s.HostID, Attached: s.attached != nil, diff --git a/core/app/service/auth.go b/core/app/service/auth.go index 040397182..c284fc19d 100644 --- a/core/app/service/auth.go +++ b/core/app/service/auth.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "net" - "net/http" "strings" "time" @@ -21,7 +20,8 @@ import ( "github.com/1Panel-dev/1Panel/core/init/session/psession" "github.com/1Panel-dev/1Panel/core/utils/encrypt" "github.com/1Panel-dev/1Panel/core/utils/passkey" - "github.com/1Panel-dev/1Panel/core/utils/req_helper/proxy_local" + terminalsession "github.com/1Panel-dev/1Panel/core/utils/terminal_session" + "github.com/1Panel-dev/1Panel/core/utils/xpack" "github.com/gin-gonic/gin" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" @@ -50,6 +50,7 @@ func NewIAuthService() IAuthService { } func (u *AuthService) LogOut(c *gin.Context) error { + identity, _ := terminalsession.FromContext(c) httpsSetting, err := settingRepo.Get(repo.WithByKey("SSL")) if err != nil { return err @@ -63,16 +64,13 @@ func (u *AuthService) LogOut(c *gin.Context) error { return err } } - CloseTerminalSessions() + CloseTerminalSessions("auth_session", identity.UserID, identity.AuthSessionID) return nil } -// CloseTerminalSessions tells the local agent to end every kept-alive web terminal. -// A logged-out panel has nobody watching, so nothing it left running should survive. -// ponytail: local agent only; shells on other nodes are the multi-node proxy's job. -func CloseTerminalSessions() { - if _, err := proxy_local.NewLocalClient("/api/v2/hosts/terminal/sessions/closeAll", http.MethodPost, nil, nil); err != nil { - global.LOG.Warnf("close terminal sessions on logout failed, err: %v", err) +func CloseTerminalSessions(scope, userID, authSessionID string) { + if err := xpack.AuthProvider.RevokeTerminalSessions(scope, userID, authSessionID); err != nil { + global.LOG.Warnf("revoke terminal sessions failed, scope=%s, err: %v", scope, err) } } diff --git a/core/app/service/setting.go b/core/app/service/setting.go index f618518d1..9473bd2ab 100644 --- a/core/app/service/setting.go +++ b/core/app/service/setting.go @@ -247,7 +247,7 @@ func (u *SettingService) Update(c *gin.Context, key, value string) error { case "BindDomain": if len(value) != 0 { _ = global.SESSION.Clean() - CloseTerminalSessions() + CloseTerminalSessions("all", "", "") } if err := u.clearPasskeySettings(); err != nil { return err @@ -615,7 +615,7 @@ func (u *SettingService) deleteCurrentSession(c *gin.Context) { return } _ = global.SESSION.DeleteByID(sessionUser.ID) - CloseTerminalSessions() + CloseTerminalSessions("user", sessionUser.ID, "") } func (u *SettingService) clearPasskeySettings() error { diff --git a/core/init/router/proxy.go b/core/init/router/proxy.go index c91926ec4..a7dc895b2 100644 --- a/core/init/router/proxy.go +++ b/core/init/router/proxy.go @@ -16,6 +16,7 @@ import ( "github.com/1Panel-dev/1Panel/core/init/proxy" psessionUtils "github.com/1Panel-dev/1Panel/core/init/session/psession" "github.com/1Panel-dev/1Panel/core/middleware" + terminalsession "github.com/1Panel-dev/1Panel/core/utils/terminal_session" "github.com/1Panel-dev/1Panel/core/utils/xpack" "github.com/gin-gonic/gin" ) @@ -24,6 +25,7 @@ var errInternalOnlyAgentEndpoint = errors.New("internal agent endpoint cannot be func Proxy() gin.HandlerFunc { return func(c *gin.Context) { + terminalsession.ClearForwardedHeaders(c) reqPath := c.Request.URL.Path if !middleware.ShouldProxyToAgent(reqPath) { c.Next() @@ -43,8 +45,9 @@ func Proxy() gin.HandlerFunc { } apiReq := c.GetBool("API_AUTH") + terminalRevalidate := c.Query("terminalRevalidate") == "1" && isTerminalRevalidationEndpoint(reqPath) - if !apiReq && !isLocalAPI(reqPath) && !middleware.IsPublicFileShareAPI(reqPath) && !checkSession(c) { + if !apiReq && !isLocalAPI(reqPath) && !middleware.IsPublicFileShareAPI(reqPath) && !checkSession(c, !terminalRevalidate) { data, _ := res.ErrorMsg.ReadFile("html/401.html") c.Data(401, "text/html; charset=utf-8", data) c.Abort() @@ -54,6 +57,9 @@ func Proxy() gin.HandlerFunc { if userName := middleware.LoadOperationUser(c); userName != "" { c.Request.Header.Set("X-Panel-User", url.QueryEscape(userName)) } + if identity, ok := terminalsession.FromContext(c); ok { + terminalsession.SetForwardedHeaders(c, identity) + } if isInternalOnlyAgentEndpoint(reqPath) { helper.ErrorWithDetail(c, http.StatusForbidden, "ErrProxy", errInternalOnlyAgentEndpoint) @@ -74,11 +80,21 @@ func Proxy() gin.HandlerFunc { } } +func isTerminalRevalidationEndpoint(reqPath string) bool { + switch reqPath { + case "/api/v2/hosts/terminal/local", "/api/v2/hosts/terminal/ssh", "/api/v2/hosts/terminal/container": + return true + default: + return false + } +} + func isInternalOnlyAgentEndpoint(reqPath string) bool { normalizedPath := path.Clean(reqPath) return normalizedPath == "/api/v2/xpack/alert/offline/email" || normalizedPath == "/api/v2/xpack/alert/offline/webhook" || - normalizedPath == "/api/v2/hosts/firewall/port" + normalizedPath == "/api/v2/hosts/firewall/port" || + normalizedPath == "/api/v2/internal/terminal/sessions/revoke" } func proxyLocalAgent(c *gin.Context) { @@ -91,7 +107,7 @@ func proxyLocalAgent(c *gin.Context) { c.Abort() } -func checkSession(c *gin.Context) bool { +func checkSession(c *gin.Context, refresh bool) bool { psession, err := global.SESSION.Get(c) if err != nil { return false @@ -103,6 +119,9 @@ func checkSession(c *gin.Context) bool { return false } lifeTime, _ := strconv.Atoi(sessionTimeout) + if !refresh { + return true + } if _, err := global.SESSION.RefreshIfNeeded(c, psession, global.CONF.Conn.SSL == constant.StatusEnable, lifeTime); err != nil { global.LOG.Warnf("proxy refresh session failed, path=%s, err=%v", c.Request.URL.Path, err) return false diff --git a/core/server/server.go b/core/server/server.go index 104396bf5..ed908626a 100644 --- a/core/server/server.go +++ b/core/server/server.go @@ -53,6 +53,7 @@ func Start() { service.SyncScriptLibraryOnStartup() proxy.Init() + service.CloseTerminalSessions("all", "", "") rootRouter := router.Routers() diff --git a/core/utils/terminal_session/identity.go b/core/utils/terminal_session/identity.go new file mode 100644 index 000000000..cc9eda234 --- /dev/null +++ b/core/utils/terminal_session/identity.go @@ -0,0 +1,61 @@ +package terminal_session + +import ( + "crypto/sha256" + "encoding/hex" + + "github.com/1Panel-dev/1Panel/core/constant" + "github.com/1Panel-dev/1Panel/core/init/session/psession" + "github.com/gin-gonic/gin" +) + +const ( + HeaderUserID = "X-Panel-User-ID" + HeaderAuthSessionID = "X-Panel-Auth-Session-ID" +) + +type Identity struct { + UserID string + AuthSessionID string +} + +func FromContext(c *gin.Context) (Identity, bool) { + if c == nil { + return Identity{}, false + } + value, ok := c.Get(psession.GinContextSessionUserKey) + if !ok { + return Identity{}, false + } + user, ok := value.(psession.SessionUser) + if !ok || user.ID == "" { + return Identity{}, false + } + if c.GetBool("API_AUTH") { + return Identity{UserID: user.ID, AuthSessionID: APIAuthSessionID(user.ID)}, true + } + sessionID, err := c.Cookie(constant.SessionName) + if err != nil || sessionID == "" { + return Identity{}, false + } + return Identity{UserID: user.ID, AuthSessionID: HashAuthSessionID(sessionID)}, true +} + +func APIAuthSessionID(userID string) string { + return "api:" + userID +} + +func HashAuthSessionID(sessionID string) string { + sum := sha256.Sum256([]byte(sessionID)) + return hex.EncodeToString(sum[:]) +} + +func ClearForwardedHeaders(c *gin.Context) { + c.Request.Header.Del(HeaderUserID) + c.Request.Header.Del(HeaderAuthSessionID) +} + +func SetForwardedHeaders(c *gin.Context, identity Identity) { + c.Request.Header.Set(HeaderUserID, identity.UserID) + c.Request.Header.Set(HeaderAuthSessionID, identity.AuthSessionID) +} diff --git a/core/utils/xpack/helper/auth_helper.go b/core/utils/xpack/helper/auth_helper.go index 3dfc8094b..fce77ccb2 100644 --- a/core/utils/xpack/helper/auth_helper.go +++ b/core/utils/xpack/helper/auth_helper.go @@ -1,9 +1,20 @@ package helper import ( + "bytes" + "context" + "encoding/json" + "net/http" + "time" + "github.com/1Panel-dev/1Panel/core/app/auth" baseDto "github.com/1Panel-dev/1Panel/core/app/dto" + "github.com/1Panel-dev/1Panel/core/app/repo" + "github.com/1Panel-dev/1Panel/core/global" + "github.com/1Panel-dev/1Panel/core/init/session/psession" "github.com/1Panel-dev/1Panel/core/utils/mfa" + "github.com/1Panel-dev/1Panel/core/utils/req_helper/proxy_local" + terminalsession "github.com/1Panel-dev/1Panel/core/utils/terminal_session" "github.com/1Panel-dev/1Panel/core/utils/xpack/providers" "github.com/gin-gonic/gin" ) @@ -56,7 +67,13 @@ func (a *authHelper) ResetSuperAdminUser(name, password string) error { } func (a *authHelper) CoreAPIAuthMiddleware() gin.HandlerFunc { - return auth.APIAuthMiddleware(auth.LoadAPIAuthConfig, nil) + return auth.APIAuthMiddleware(auth.LoadAPIAuthConfig, func(c *gin.Context, _ auth.APIAuthConfig) { + name, _ := repo.NewISettingRepo().GetValueByKey("UserName") + c.Set("API_AUTH_USERNAME", name) + c.Set(psession.GinContextSessionUserKey, psession.SessionUser{ + ID: psession.SuperAdminSessionUserID, Name: name, Role: "ADMIN", + }) + }) } func (a *authHelper) CoreRBACMiddlewares() []gin.HandlerFunc { return nil } @@ -71,10 +88,25 @@ func (a *authHelper) MFAClose(_ *gin.Context) error { return auth.MFAClose() } func (a *authHelper) GenerateApiKey(_ *gin.Context) (string, error) { - return auth.GenerateApiKey() + apiKey, err := auth.GenerateApiKey() + if err != nil { + return "", err + } + userID := psession.SuperAdminSessionUserID + if err := a.RevokeTerminalSessions("auth_session", userID, terminalsession.APIAuthSessionID(userID)); err != nil { + global.LOG.Warnf("revoke API terminal sessions after API key generation failed, err: %v", err) + } + return apiKey, nil } func (a *authHelper) UpdateApiConfig(c *gin.Context, req baseDto.ApiInterfaceConfig) error { - return auth.UpdateApiConfig(req) + if err := auth.UpdateApiConfig(req); err != nil { + return err + } + userID := psession.SuperAdminSessionUserID + if err := a.RevokeTerminalSessions("auth_session", userID, terminalsession.APIAuthSessionID(userID)); err != nil { + global.LOG.Warnf("revoke API terminal sessions after API config update failed, err: %v", err) + } + return nil } func (a *authHelper) GetCurrentUserInfo(_ *gin.Context) (*baseDto.CurrentUserInfo, error) { @@ -90,8 +122,44 @@ func (a *authHelper) SyncPasswordExpirationTime(expirationDays string) error { return auth.SyncPasswordExpirationTime(expirationDays) } func (a *authHelper) UpdateCurrentUserInfo(c *gin.Context, req baseDto.CurrentUserUpdate) error { - return auth.UpdateCurrentUserInfo(c, req) + identity, _ := terminalsession.FromContext(c) + if err := auth.UpdateCurrentUserInfo(c, req); err != nil { + return err + } + if identity.UserID != "" { + if err := a.RevokeTerminalSessions("user", identity.UserID, ""); err != nil { + global.LOG.Warnf("revoke terminal sessions after user update failed, err: %v", err) + } + } + return nil } func (a *authHelper) HandlePasswordExpired(c *gin.Context, old, new string) error { - return auth.HandlePasswordExpired(c, old, new) + identity, _ := terminalsession.FromContext(c) + if err := auth.HandlePasswordExpired(c, old, new); err != nil { + return err + } + if identity.UserID != "" { + if err := a.RevokeTerminalSessions("user", identity.UserID, ""); err != nil { + global.LOG.Warnf("revoke terminal sessions after password change failed, err: %v", err) + } + } + return nil +} + +func (a *authHelper) RevokeTerminalSessions(scope, userID, authSessionID string) error { + body, err := json.Marshal(map[string]string{ + "scope": scope, "userId": userID, "authSessionId": authSessionID, + }) + if err != nil { + return err + } + _, err = proxy_local.NewLocalClientWithContext( + context.Background(), + "/api/v2/internal/terminal/sessions/revoke", + http.MethodPost, + bytes.NewReader(body), + nil, + 5*time.Second, + ) + return err } diff --git a/core/utils/xpack/providers/auth.go b/core/utils/xpack/providers/auth.go index 6e5fcc770..dc7a5a598 100644 --- a/core/utils/xpack/providers/auth.go +++ b/core/utils/xpack/providers/auth.go @@ -35,6 +35,7 @@ type AuthProvider interface { SyncPasswordExpirationTime(expirationDays string) error UpdateCurrentUserInfo(c *gin.Context, req dto.CurrentUserUpdate) error HandlePasswordExpired(c *gin.Context, old, new string) error + RevokeTerminalSessions(scope, userID, authSessionID string) error CoreAPIAuthMiddleware() gin.HandlerFunc CoreRBACMiddlewares() []gin.HandlerFunc diff --git a/frontend/src/api/interface/terminal.ts b/frontend/src/api/interface/terminal.ts index 7b9135af1..8e7178574 100644 --- a/frontend/src/api/interface/terminal.ts +++ b/frontend/src/api/interface/terminal.ts @@ -10,6 +10,7 @@ export interface ReqTerminal { export interface TerminalSession { id: string; + kind: 'local' | 'ssh' | 'container'; title: string; hostId: number; attached: boolean; diff --git a/frontend/src/components/terminal/dock/index.vue b/frontend/src/components/terminal/dock/index.vue index 244996ebc..bd1017937 100644 --- a/frontend/src/components/terminal/dock/index.vue +++ b/frontend/src/components/terminal/dock/index.vue @@ -1,22 +1,24 @@