mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-09-22 00:00:50 +00:00
fix: harden terminal session lifecycle (#13736)
This commit is contained in:
@@ -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), " ")
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -53,6 +53,7 @@ func Start() {
|
||||
service.SyncScriptLibraryOnStartup()
|
||||
|
||||
proxy.Init()
|
||||
service.CloseTerminalSessions("all", "", "")
|
||||
|
||||
rootRouter := router.Routers()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ReqTerminal {
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
kind: 'local' | 'ssh' | 'container';
|
||||
title: string;
|
||||
hostId: number;
|
||||
attached: boolean;
|
||||
|
||||
@@ -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>
|
||||
{{ 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user