Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions agent/app/api/v2/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,17 +300,26 @@ func (b *BaseApi) UpdateAlertConfig(c *gin.Context) {
helper.Success(c)
}

func loadAuditUser(c *gin.Context) string {
// panelUser is the panel user the core proxy identified via the X-Panel-User
// header, or "" when the caller is unknown.
func panelUser(c *gin.Context) string {
userName := strings.TrimSpace(c.GetHeader("X-Panel-User"))
if userName == "" {
return defaultAuditUser
return ""
}
if decoded, err := url.QueryUnescape(userName); err == nil {
return decoded
}
return userName
}

func loadAuditUser(c *gin.Context) string {
if user := panelUser(c); user != "" {
return user
}
return defaultAuditUser
}

// @Tags Alert
// @Summary Delete alert config
// @Accept json
Expand Down
2 changes: 2 additions & 0 deletions agent/app/api/v2/entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,6 @@ var (
alertService = service.NewIAlertService()

diskService = service.NewIDiskService()

terminalSessionService = service.NewITerminalSessionService()
)
176 changes: 157 additions & 19 deletions agent/app/api/v2/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"unicode"

"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
Expand All @@ -21,6 +23,12 @@ import (
"github.com/pkg/errors"
)

// closeCodeSessionNotFound is sent when the session is gone or not owned by the caller.
const closeCodeSessionNotFound = 4404

// maxTerminalTitleRunes caps the client supplied tab name.
const maxTerminalTitleRunes = 64

// @Tags Terminal
// @Summary Ws local terminal
// @Param command query string false "command"
Expand All @@ -29,7 +37,11 @@ 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, sshSessionOption{
kind: terminal.SessionKindLocal,
connect: loadLocalConn,
command: c.DefaultQuery("command", ""),
})
}

// @Tags Terminal
Expand All @@ -41,14 +53,19 @@ 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) {
hostID, _ := strconv.Atoi(c.DefaultQuery("id", "0"))
if hostID <= 0 {
return nil, errors.New("missing host id")
}
host, err := service.GetHostInfo(uint(hostID))
return newHostSSHClient(host, err)
}, c.DefaultQuery("command", ""))
hostID, _ := strconv.Atoi(c.DefaultQuery("id", "0"))
b.runSSHSession(c, sshSessionOption{
kind: terminal.SessionKindSSH,
hostID: uint(max(hostID, 0)),
connect: func() (*ssh.SSHClient, error) {
if hostID <= 0 {
return nil, errors.New("missing host id")
}
host, err := service.GetHostInfo(uint(hostID))
return newHostSSHClient(host, err)
},
command: c.DefaultQuery("command", ""),
})
}

// @Tags Terminal
Expand Down Expand Up @@ -115,39 +132,160 @@ 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) {
type sshSessionOption struct {
kind string
hostID uint
connect func() (*ssh.SSHClient, error)
command string
}

func (b *BaseApi) runSSHSession(c *gin.Context, opt sshSessionOption) {
wsConn, cols, rows, ok := prepareTerminalSession(c)
if !ok {
return
}
defer wsConn.Close()

client, clientErr := connect()
owner := panelUser(c)
if sessionID := strings.TrimSpace(c.Query("session")); len(sessionID) != 0 {
attachTerminalSession(wsConn, sessionID, owner, cols, rows)
return
}

client, clientErr := opt.connect()
if wshandleError(wsConn, errors.WithMessage(clientErr, "failed to set up the connection. Please check the host information")) {
return
}
defer client.Close()

sws, err := terminal.NewLogicSshWsSession(cols, rows, client.Client, wsConn, command)
if wshandleError(wsConn, err) {
sess, err := terminal.DefaultManager.OpenSession(client.Client, terminal.SessionOptions{
Kind: opt.kind,
HostID: opt.hostID,
Title: sanitizeTerminalTitle(c.Query("title")),
Owner: owner,
Cols: cols,
Rows: rows,
InitCmd: opt.command,
})
if err != nil {
// session does not exist yet, so we still own the ssh client
client.Close()
_ = wshandleError(wsConn, err)
return
}
defer sws.Close()
// no defer sess.Close(): pinned sessions outlive this websocket
att, err := sess.Attach(wsConn, cols, rows)
if err != nil {
sess.Close()
_ = wshandleError(wsConn, err)
return
}
att.Run()

quitChan := make(chan bool, 3)
sws.Start(quitChan)
go sws.Wait(quitChan)
closeTerminalConn(wsConn)
}

<-quitChan
// attachTerminalSession binds the websocket to an existing session.
func attachTerminalSession(wsConn *websocket.Conn, sessionID, owner string, cols, rows int) {
sess, err := terminal.DefaultManager.Lookup(sessionID, owner)
if err != nil {
closeTerminalConnWithCode(wsConn, closeCodeSessionNotFound, "session not found")
return
}
att, err := sess.Attach(wsConn, cols, rows)
if err != nil {
global.LOG.Errorf("attach terminal session %s failed, err: %v", sessionID, err)
closeTerminalConnWithCode(wsConn, closeCodeSessionNotFound, "session not found")
return
}
att.Run()

closeTerminalConn(wsConn)
}

// sanitizeTerminalTitle keeps a client supplied tab name printable and short.
func sanitizeTerminalTitle(title string) string {
var builder strings.Builder
count := 0
for _, r := range strings.TrimSpace(title) {
if unicode.IsControl(r) {
continue
}
if count == maxTerminalTitleRunes {
break
}
builder.WriteRune(r)
count++
}
return strings.TrimSpace(builder.String())
}

// @Tags Terminal
// @Summary Search terminal sessions
// @Accept json
// @Success 200 {array} dto.TerminalSessionInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/terminal/sessions/search [post]
func (b *BaseApi) SearchTerminalSessions(c *gin.Context) {
list, err := terminalSessionService.List(panelUser(c))
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, list)
}

// @Tags Terminal
// @Summary Pin or unpin a terminal session
// @Accept json
// @Param request body dto.TerminalSessionPin true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/terminal/sessions/pin [post]
func (b *BaseApi) PinTerminalSession(c *gin.Context) {
var req dto.TerminalSessionPin
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := terminalSessionService.Pin(panelUser(c), req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}

// @Tags Terminal
// @Summary Close a terminal session
// @Accept json
// @Param request body dto.TerminalSessionClose true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/terminal/sessions/close [post]
func (b *BaseApi) CloseTerminalSession(c *gin.Context) {
var req dto.TerminalSessionClose
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := terminalSessionService.Close(panelUser(c), req.ID); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}

func closeTerminalConn(wsConn *websocket.Conn) {
dt := time.Now().Add(time.Second)
_ = wsConn.WriteControl(websocket.CloseMessage, nil, dt)
}

// closeTerminalConnWithCode reports a terminal specific failure to the client.
func closeTerminalConnWithCode(wsConn *websocket.Conn, code int, reason string) {
dt := time.Now().Add(time.Second)
_ = wsConn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(code, reason), dt)
}

func newHostSSHClient(host *model.Host, err error) (*ssh.SSHClient, error) {
if err != nil {
return nil, errors.WithMessage(err, "load host info by id failed")
Expand Down
6 changes: 5 additions & 1 deletion agent/app/dto/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ type SettingInfo struct {
LocalSSHConnShow string `json:"localSSHConnShow"`

FirewallPortWhiteList string `json:"firewallPortWhiteList"`

TerminalSessionKeepAlive string `json:"terminalSessionKeepAlive"`
TerminalSessionMaxPinned string `json:"terminalSessionMaxPinned"`
TerminalSessionBuffer string `json:"terminalSessionBuffer"`
}

type SettingUpdate struct {
Expand All @@ -35,7 +39,7 @@ type SettingUpdate struct {
}

type AgentSettingUpdate struct {
Key string `json:"key" validate:"required,oneof=SystemIP DockerSockPath FileRecycleBin FirewallPortWhiteList"`
Key string `json:"key" validate:"required,oneof=SystemIP DockerSockPath FileRecycleBin FirewallPortWhiteList TerminalSessionKeepAlive TerminalSessionMaxPinned TerminalSessionBuffer"`
Value string `json:"value"`
}

Expand Down
28 changes: 28 additions & 0 deletions agent/app/dto/terminal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package dto

import "time"

// TerminalSessionInfo describes one live web terminal session.
type TerminalSessionInfo struct {
ID string `json:"id"`
Kind string `json:"kind"`
HostID uint `json:"hostId"`
Title string `json:"title"`
Pinned bool `json:"pinned"`
Attached bool `json:"attached"`
CreatedAt time.Time `json:"createdAt"`
LastActiveAt time.Time `json:"lastActiveAt"`
// set only while no websocket is bound
DetachedAt *time.Time `json:"detachedAt,omitempty"`
// set only for a pinned session that is currently detached
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}

type TerminalSessionPin struct {
ID string `json:"id" validate:"required"`
Pinned bool `json:"pinned"`
}

type TerminalSessionClose struct {
ID string `json:"id" validate:"required"`
}
15 changes: 15 additions & 0 deletions agent/app/repo/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type ISettingRepo interface {
GetList(opts ...DBOption) ([]model.Setting, error)
Get(opts ...DBOption) (model.Setting, error)
GetValueByKey(key string) (string, error)
GetValuesByKeys(keys []string) (map[string]string, error)
Create(key, value string) error
Update(key, value string) error
WithByKey(key string) DBOption
Expand Down Expand Up @@ -68,6 +69,20 @@ func (s *SettingRepo) GetValueByKey(key string) (string, error) {
return setting.Value, nil
}

// GetValuesByKeys fetches several settings in one query. Missing keys are
// simply absent from the result.
func (s *SettingRepo) GetValuesByKeys(keys []string) (map[string]string, error) {
var settings []model.Setting
if err := global.DB.Where("key in (?)", keys).Find(&settings).Error; err != nil {
return nil, err
}
values := make(map[string]string, len(settings))
for _, setting := range settings {
values[setting.Key] = setting.Value
}
return values, nil
}

func (s *SettingRepo) WithByKey(key string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("key = ?", key)
Expand Down
Loading