fix: harden terminal session lifecycle (#13736)

This commit is contained in:
ssongliu
2026-09-08 09:29:01 +08:00
committed by GitHub
parent bad022f524
commit a15e77d605
19 changed files with 789 additions and 206 deletions
+84 -30
View File
@@ -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), " ")
+6
View File
@@ -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"`
}
+1
View File
@@ -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)
+25 -3
View File
@@ -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)
+46
View File
@@ -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()
}
+33 -19
View File
@@ -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)
}
+99 -17
View File
@@ -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
}
+144 -37
View File
@@ -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,
+7 -9
View File
@@ -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)
}
}
+2 -2
View File
@@ -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 {
+22 -3
View File
@@ -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
+1
View File
@@ -53,6 +53,7 @@ func Start() {
service.SyncScriptLibraryOnStartup()
proxy.Init()
service.CloseTerminalSessions("all", "", "")
rootRouter := router.Routers()
+61
View File
@@ -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)
}
+73 -5
View File
@@ -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
}
+1
View File
@@ -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
+1
View File
@@ -10,6 +10,7 @@ export interface ReqTerminal {
export interface TerminalSession {
id: string;
kind: 'local' | 'ssh' | 'container';
title: string;
hostId: number;
attached: boolean;
+165 -78
View File
@@ -1,22 +1,24 @@
<template>
<!-- Right edge handle: open a terminal from any page without leaving it. Hidden on the terminal page itself. -->
<div v-if="!onTerminalPage" class="terminal-dock-handle" @click="show">
<el-badge :value="store.entries.length" :hidden="store.entries.length === 0" type="primary">
<el-badge
:value="store.entries.length"
:hidden="store.entries.length === 0"
type="primary"
class="terminal-dock-badge"
>
<svg-icon iconName="p-terminal2" class="terminal-dock-icon" />
</el-badge>
<span class="terminal-dock-label">{{ $t('menu.terminal') }}</span>
</div>
<el-dialog
<DialogPro
v-model="open"
:title="$t('menu.terminal')"
width="70%"
draggable
:close-on-click-modal="false"
:modal="false"
size="w-70"
:show-close="false"
class="terminal-dock-dialog"
@closed="park"
:modal="false"
@opened="claim"
>
<!-- minimize keeps sessions alive; X closes them all (with confirm) -->
<template #header>
@@ -30,71 +32,81 @@
</el-tooltip>
</div>
</template>
<div class="flex items-center gap-1 mb-1">
<el-tabs v-model="active" type="card" closable class="flex-1 terminal-dock-tabs" @tab-remove="store.remove">
<el-tab-pane v-for="item in store.entries" :key="item.key" :name="item.key">
<template #label>
<span :style="{ color: item.status === 'online' ? '#69db7c' : '#d9480f' }"></span>
&nbsp;{{ item.title }}
</template>
</el-tab-pane>
</el-tabs>
<!-- same picker as the terminal page: local shell + ssh host tree -->
<el-popover trigger="click" width="280px" @before-enter="loadHosts">
<template #reference>
<el-button icon="Plus" circle size="small" />
</template>
<el-button link class="w-full" @click="connect(0, $t('terminal.localhost'))">
<el-icon class="mr-1"><House /></el-icon>
{{ $t('terminal.localhost') }}
</el-button>
<template v-if="!isNodeAdmin">
<el-divider class="my-1" />
<el-input
v-model="hostFilter"
size="small"
clearable
:placeholder="$t('commons.button.search')"
class="mb-1"
/>
<el-tree
ref="treeRef"
node-key="id"
default-expand-all
:expand-on-click-node="false"
:data="hostTree"
:filter-node-method="filterHost"
:empty-text="$t('terminal.noHost')"
class="terminal-dock-tree"
>
<template #default="{ node, data }">
<span v-if="node.level === 1" class="text-xs font-medium">
{{ node.label === 'Default' ? $t('commons.table.default') : node.label }}
</span>
<a
v-else
class="text-xs hover:text-[var(--el-color-primary)] truncate"
:title="node.label"
@click="connect(data.id, node.label)"
>
{{ node.label }}
</a>
<template #content>
<div class="terminal-dock-toolbar">
<el-tabs v-model="active" type="card" closable class="terminal-dock-tabs" @tab-remove="store.remove">
<el-tab-pane v-for="item in store.entries" :key="item.key" :name="item.key">
<template #label>
<span
class="terminal-status-dot"
:class="item.status === 'online' ? 'is-online' : 'is-offline'"
></span>
<span class="terminal-tab-title" :title="item.title">{{ item.title }}</span>
</template>
</el-tree>
</template>
</el-popover>
</div>
<div v-if="store.entries.length === 0" class="terminal-dock-empty">{{ $t('terminal.emptyTerminal') }}</div>
<!-- one slot per entry; the active one claims its Terminal from the host, the rest stay parked -->
<div
v-for="item in store.entries"
v-show="item.key === active"
:key="item.key"
class="terminal-dock-slot"
:ref="(el: any) => onSlot(item.key, el)"
@click="store.instances[item.key]?.refit()"
></div>
</el-dialog>
</el-tab-pane>
</el-tabs>
<el-popover trigger="click" width="280px" @before-enter="loadHosts">
<template #reference>
<el-button type="primary" plain icon="Plus" size="small">
{{ $t('terminal.createConn') }}
</el-button>
</template>
<el-button link class="w-full" @click="connect(0, $t('terminal.localhost'))">
<el-icon class="mr-1"><House /></el-icon>
{{ $t('terminal.localhost') }}
</el-button>
<template v-if="!isNodeAdmin">
<el-divider class="my-1" />
<el-input
v-model="hostFilter"
size="small"
clearable
:placeholder="$t('commons.button.search')"
class="mb-1"
/>
<el-tree
ref="treeRef"
node-key="id"
default-expand-all
:expand-on-click-node="false"
:data="hostTree"
:filter-node-method="filterHost"
:empty-text="$t('terminal.noHost')"
class="terminal-dock-tree"
>
<template #default="{ node, data }">
<span v-if="node.level === 1" class="text-xs font-medium">
{{ node.label === 'Default' ? $t('commons.table.default') : node.label }}
</span>
<a
v-else
class="text-xs hover:text-[var(--el-color-primary)] truncate"
:title="node.label"
@click="connect(data.id, node.label)"
>
{{ node.label }}
</a>
</template>
</el-tree>
</template>
</el-popover>
</div>
<div v-if="store.entries.length === 0" class="terminal-dock-empty">
{{ $t('terminal.emptyTerminal') }}
</div>
<div
v-for="item in store.entries"
v-show="item.key === active"
:key="item.key"
class="terminal-dock-slot"
:ref="(el: any) => onSlot(item.key, el)"
@click="store.instances[item.key]?.refit()"
></div>
</template>
</DialogPro>
</template>
<script setup lang="ts">
@@ -133,9 +145,10 @@ const park = () => {
timer = null;
claim();
};
watch(open, (value) => {
if (!value) park();
});
// The dialog keeps its content mounted while hidden, so slots are claimed explicitly:
// only the visible pane owns its Terminal (claiming a hidden one would fit it to 0x0).
const slotEls: Record<string, HTMLElement> = {};
const onSlot = (key: string, el: HTMLElement | null) => {
if (el) slotEls[key] = el;
@@ -227,29 +240,103 @@ watch(onTerminalPage, (v) => {
color: var(--el-color-primary);
cursor: pointer;
user-select: none;
transition: background-color 0.2s;
&:hover {
background-color: var(--el-fill-color-light);
}
}
.terminal-dock-icon {
width: 20px;
height: 20px;
}
.terminal-dock-badge {
--el-badge-size: 14px;
--el-badge-font-size: 10px;
--el-badge-padding: 4px;
--el-badge-radius: 7px;
}
.terminal-dock-label {
writing-mode: vertical-rl;
font-size: 12px;
letter-spacing: 2px;
}
.terminal-dock-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.terminal-dock-tabs {
min-width: 0;
flex: 1;
:deep(.el-tabs__header) {
margin-bottom: 0;
}
:deep(.el-tabs__item) {
max-width: 220px;
height: 34px;
padding: 0 12px;
}
}
.terminal-status-dot {
width: 7px;
height: 7px;
flex: 0 0 auto;
margin-right: 7px;
border-radius: 50%;
&.is-online {
background-color: var(--el-color-success);
}
&.is-offline {
background-color: var(--el-color-danger);
}
}
.terminal-tab-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.terminal-dock-tree {
max-height: 40vh;
overflow: auto;
}
.terminal-dock-slot {
height: 60vh;
background-color: var(--panel-logs-bg-color);
}
.terminal-dock-slot,
.terminal-dock-empty {
height: 60vh;
overflow: hidden;
border-radius: 6px;
}
.terminal-dock-slot {
background-color: var(--panel-logs-bg-color);
}
.terminal-dock-empty {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--el-fill-color-extra-light);
color: var(--el-text-color-secondary);
}
@media (max-width: 768px) {
.terminal-dock-slot,
.terminal-dock-empty {
height: 70vh;
}
}
</style>
+17 -3
View File
@@ -36,6 +36,7 @@ const emit = defineEmits(['session', 'expired']);
// Close codes of the agent's session protocol (agent/utils/terminal/session.go).
const CLOSE_SESSION_NOT_FOUND = 4404;
const CLOSE_ATTACHED_ELSEWHERE = 4409;
const CLOSE_REVALIDATE = 4410;
const terminalElement = ref<HTMLDivElement | null>(null);
const fitAddon = new FitAddon();
@@ -53,6 +54,7 @@ let wsEndpoint = '';
let wsArgs = '';
let closing = false;
let reconnecting = false;
let revalidating = false;
let reconnectStartedAt = 0;
let reconnectDelay = 1000;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -290,13 +292,19 @@ const initWebSocket = async (endpoint_: string, args: string = '') => {
if (sessionId.value) {
conn += `&session=${encodeURIComponent(sessionId.value)}`;
}
if (revalidating) {
conn += '&terminalRevalidate=1';
}
const authError = await checkStreamAuth(conn);
if (token !== initWebSocketToken || !termReady.value) {
return;
}
if (authError) {
reconnecting = false;
revalidating = false;
sessionId.value = '';
showWebSocketAuthError(authError);
emit('expired');
return;
}
if (heartbeatTimer.value) {
@@ -395,10 +403,12 @@ const onWSReceive = (message: MessageEvent) => {
}
case 'session': {
const wasReconnect = reconnecting;
const wasRevalidate = revalidating;
reconnecting = false;
revalidating = false;
reconnectDelay = 1000;
sessionId.value = wsMsg.id || '';
if (wasReconnect) {
if (wasReconnect && !wasRevalidate) {
// replay is a tail of recent output, start from a clean screen
term.value?.reset();
}
@@ -433,8 +443,7 @@ const closeRealTerminal = (ev: CloseEvent) => {
}
terminalSocket.value = undefined;
if (closing || !sessionId.value) {
// deliberate close, or a terminal without an agent side session (container, app, ...)
term.value?.write('The connection has been disconnected.');
term.value?.write('The connection has been disconnected.');
term.value?.write(ev.reason);
return;
}
@@ -453,6 +462,10 @@ const closeRealTerminal = (ev: CloseEvent) => {
reconnecting = false;
writeNotice('31', i18n.global.t('terminal.sessionKicked'));
return;
case CLOSE_REVALIDATE:
revalidating = true;
scheduleReconnect();
return;
default:
scheduleReconnect();
}
@@ -487,6 +500,7 @@ const scheduleReconnect = () => {
const stopReconnect = () => {
reconnecting = false;
revalidating = false;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
@@ -107,6 +107,7 @@ const TerminalSessionStore = defineStore('TerminalSessionStore', () => {
if (r.status !== 'fulfilled') return;
const fromLocalNode = i === 1 || node === 'local';
for (const s of r.value.data || []) {
if (s.kind !== 'local' && s.kind !== 'ssh') continue;
// attached elsewhere = another browser tab is using it; do not steal it
if (s.attached || entries.value.some((e) => e.sessionId === s.id)) continue;
if (s.hostId > 0 && !fromLocalNode) continue; // ssh sessions are served by the local node