mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-12 16:00:33 +00:00
Replace database rule synchronization with native rule operations and file-based backup, import, and recovery. Update firewall management views, descriptions, whitelist handling, and translations. Include terminal reconnect fixes and xpack build tags for Darwin build targets. Remove temporary regression test files before committing.
487 lines
11 KiB
Go
487 lines
11 KiB
Go
package terminal
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/1Panel-dev/1Panel/agent/global"
|
|
"github.com/1Panel-dev/1Panel/agent/i18n"
|
|
terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai"
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/websocket"
|
|
gossh "golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
const (
|
|
graceTimeout = 30 * time.Minute
|
|
revalidateInterval = 60 * time.Second
|
|
revalidateGrace = 30 * time.Second
|
|
keepaliveInterval = 30 * time.Second
|
|
keepaliveTimeout = 10 * time.Second
|
|
pumpInterval = 60 * time.Millisecond
|
|
)
|
|
|
|
const (
|
|
CloseCodeSessionNotFound = 4404
|
|
CloseCodeAttachedElsewhere = 4409
|
|
CloseCodeRevalidate = 4410
|
|
)
|
|
|
|
var errSessionClosed = errors.New("terminal session is closed")
|
|
|
|
type SessionOptions struct {
|
|
Identity Identity
|
|
Kind string
|
|
Target string
|
|
Title string
|
|
Persistent bool
|
|
HostID uint // 0 = local shell
|
|
Cols int
|
|
Rows int
|
|
InitCmd string
|
|
}
|
|
|
|
type Info struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Title string `json:"title"`
|
|
Persistent bool `json:"persistent"`
|
|
HostID uint `json:"hostId"`
|
|
Attached bool `json:"attached"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
DetachedAt time.Time `json:"detachedAt"` // zero while attached
|
|
}
|
|
|
|
type Session struct {
|
|
ID string
|
|
UserID string
|
|
AuthSessionID string
|
|
Kind string
|
|
Target string
|
|
Title string
|
|
Persistent bool
|
|
HostID uint
|
|
CreatedAt time.Time
|
|
|
|
mu sync.Mutex
|
|
attached *attachment
|
|
detachedAt time.Time
|
|
grace *time.Timer
|
|
revalidateCursor uint64
|
|
revalidatePending bool
|
|
authLeaseUntil time.Time
|
|
authLeaseTimer *time.Timer
|
|
authLeaseVersion uint64
|
|
authLeaseExpired bool
|
|
cols int
|
|
rows int
|
|
|
|
backend sessionBackend
|
|
ring *ringBuffer
|
|
|
|
lang string
|
|
aiInterceptor *aiInputInterceptor
|
|
aiVersion uint64
|
|
|
|
done chan struct{}
|
|
closeFn func()
|
|
}
|
|
|
|
type sessionBackend interface {
|
|
io.Writer
|
|
Resize(cols, rows int) error
|
|
Wait() error
|
|
Keepalive() error
|
|
Close() error
|
|
}
|
|
|
|
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.Identity)
|
|
if ok && sess.Kind == opts.Kind && sess.Target == opts.Target && sess.Persistent == opts.Persistent && sess.HostID == opts.HostID {
|
|
if !sess.renewAuthLease(opts.Identity.AuthLeaseUntil) {
|
|
sess.Close()
|
|
sendClose(ws, CloseCodeRevalidate, "terminal authorization expired")
|
|
return nil
|
|
}
|
|
att, err := sess.Attach(ws, opts.Cols, opts.Rows)
|
|
if err == nil {
|
|
att.Run()
|
|
return nil
|
|
}
|
|
global.LOG.Errorf("attach terminal session %s failed, err: %v", sessionID, err)
|
|
}
|
|
sendClose(ws, CloseCodeSessionNotFound, "session not found")
|
|
return nil
|
|
}
|
|
|
|
sess, err := open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
att, err := sess.Attach(ws, opts.Cols, opts.Rows)
|
|
if err != nil {
|
|
sess.Close()
|
|
return err
|
|
}
|
|
att.Run()
|
|
return nil
|
|
}
|
|
|
|
func Open(client *gossh.Client, opts SessionOptions) (*Session, error) {
|
|
if err := validateSessionOptions(opts); err != nil {
|
|
return nil, err
|
|
}
|
|
ring := newRingBuffer()
|
|
backend, err := newSSHBackend(client, opts.Cols, opts.Rows, opts.InitCmd, ring)
|
|
if err != nil {
|
|
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")
|
|
}
|
|
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(),
|
|
UserID: opts.Identity.UserID,
|
|
AuthSessionID: opts.Identity.AuthSessionID,
|
|
Kind: opts.Kind,
|
|
Target: opts.Target,
|
|
Title: opts.Title,
|
|
Persistent: opts.Persistent,
|
|
HostID: opts.HostID,
|
|
CreatedAt: time.Now(),
|
|
cols: opts.Cols,
|
|
rows: opts.Rows,
|
|
backend: backend,
|
|
ring: ring,
|
|
lang: lang,
|
|
aiInterceptor: newAIInputInterceptor("", lang),
|
|
aiVersion: terminalai.CurrentTerminalRuntimeVersion(),
|
|
done: make(chan struct{}),
|
|
}
|
|
s.closeFn = sync.OnceFunc(s.doClose)
|
|
registerSession(s)
|
|
if !s.renewAuthLease(opts.Identity.AuthLeaseUntil) {
|
|
s.Close()
|
|
return s
|
|
}
|
|
go s.pump()
|
|
go s.keepaliveLoop()
|
|
go s.waitBackend()
|
|
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
|
|
}
|
|
|
|
func (s *Session) Attach(ws *websocket.Conn, cols, rows int) (*attachment, error) {
|
|
if ws == nil {
|
|
return nil, errors.New("nil websocket connection")
|
|
}
|
|
att := &attachment{sess: s, ws: ws, done: make(chan struct{})}
|
|
|
|
s.mu.Lock()
|
|
select {
|
|
case <-s.done:
|
|
s.mu.Unlock()
|
|
return nil, errSessionClosed
|
|
default:
|
|
}
|
|
previous := s.attached
|
|
s.attached = att
|
|
s.detachedAt = time.Time{}
|
|
if s.grace != nil {
|
|
s.grace.Stop()
|
|
s.grace = nil
|
|
}
|
|
if cols > 0 {
|
|
s.cols = cols
|
|
}
|
|
if rows > 0 {
|
|
s.rows = rows
|
|
}
|
|
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()
|
|
|
|
if previous != nil {
|
|
previous.close(CloseCodeAttachedElsewhere, "attached elsewhere")
|
|
}
|
|
|
|
err := func() error {
|
|
defer att.writeMu.Unlock()
|
|
hello, err := json.Marshal(WsMsg{Type: WsMsgSession, ID: s.ID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := att.writeLocked(hello); err != nil {
|
|
return err
|
|
}
|
|
data, next, _ := s.ring.ReadFrom(att.cursor)
|
|
att.cursor = next
|
|
if len(data) == 0 {
|
|
return nil
|
|
}
|
|
return att.writeLocked(cmdMessage(data))
|
|
}()
|
|
if err != nil {
|
|
att.close(websocket.CloseInternalServerErr, "attach failed")
|
|
s.detach(att, false, false, 0)
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.backend.Resize(cols, rows); err != nil {
|
|
global.LOG.Errorf("ssh pty change windows size failed, err: %v", err)
|
|
}
|
|
return att, nil
|
|
}
|
|
|
|
func (s *Session) detach(a *attachment, clean, revalidate bool, cursor uint64) {
|
|
s.mu.Lock()
|
|
if s.attached != a {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.attached = nil
|
|
s.detachedAt = time.Now()
|
|
s.revalidatePending = revalidate
|
|
if revalidate {
|
|
s.revalidateCursor = cursor
|
|
}
|
|
if !clean {
|
|
timeout := graceTimeout
|
|
if !s.Persistent || revalidate {
|
|
timeout = revalidateGrace
|
|
}
|
|
s.grace = time.AfterFunc(timeout, s.Close)
|
|
}
|
|
s.mu.Unlock()
|
|
if clean {
|
|
s.Close()
|
|
}
|
|
}
|
|
|
|
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() }
|
|
|
|
func (s *Session) doClose() {
|
|
close(s.done)
|
|
s.mu.Lock()
|
|
att := s.attached
|
|
s.attached = nil
|
|
if s.grace != nil {
|
|
s.grace.Stop()
|
|
}
|
|
if s.authLeaseTimer != nil {
|
|
s.authLeaseTimer.Stop()
|
|
}
|
|
s.mu.Unlock()
|
|
if att != nil {
|
|
att.close(websocket.CloseNormalClosure, "")
|
|
}
|
|
unregisterSession(s)
|
|
if err := s.backend.Close(); err != nil {
|
|
global.LOG.Debugf("close terminal backend: %v", err)
|
|
}
|
|
}
|
|
|
|
func (s *Session) Info() Info {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return Info{
|
|
ID: s.ID,
|
|
Kind: s.Kind,
|
|
Title: s.Title,
|
|
Persistent: s.Persistent,
|
|
HostID: s.HostID,
|
|
Attached: s.attached != nil,
|
|
CreatedAt: s.CreatedAt,
|
|
DetachedAt: s.detachedAt,
|
|
}
|
|
}
|
|
|
|
func (s *Session) resize(cols, rows int) {
|
|
s.mu.Lock()
|
|
s.cols, s.rows = cols, rows
|
|
s.mu.Unlock()
|
|
if err := s.backend.Resize(cols, rows); err != nil {
|
|
global.LOG.Errorf("ssh pty change windows size failed, err: %v", err)
|
|
}
|
|
}
|
|
|
|
func (s *Session) writeInput(data []byte) {
|
|
if _, err := s.backend.Write(data); err != nil {
|
|
global.LOG.Errorf("ws cmd bytes write to ssh.stdin pipe failed, err: %v", err)
|
|
}
|
|
}
|
|
|
|
func (s *Session) ensureAIInterceptor() *aiInputInterceptor {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if v := terminalai.CurrentTerminalRuntimeVersion(); s.aiInterceptor == nil || s.aiVersion != v {
|
|
s.aiVersion = v
|
|
s.aiInterceptor = newAIInputInterceptor("", s.lang)
|
|
}
|
|
return s.aiInterceptor
|
|
}
|
|
|
|
func (s *Session) pump() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
global.LOG.Errorf("a panic occurred during send combo output, error message: %v", r)
|
|
}
|
|
}()
|
|
tick := time.NewTicker(pumpInterval)
|
|
defer tick.Stop()
|
|
for {
|
|
select {
|
|
case <-s.done:
|
|
return
|
|
case <-tick.C:
|
|
s.flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Session) flush() {
|
|
s.mu.Lock()
|
|
att := s.attached
|
|
s.mu.Unlock()
|
|
if att == nil {
|
|
return
|
|
}
|
|
att.writeMu.Lock()
|
|
defer att.writeMu.Unlock()
|
|
data, next, lost := s.ring.ReadFrom(att.cursor)
|
|
if lost {
|
|
notice := "\r\n\x1b[33m" + i18n.GetMsgByKeyAndLang(s.lang, "TerminalOutputTruncated") + "\x1b[m\r\n"
|
|
if err := att.writeLocked(cmdMessage([]byte(notice))); err != nil {
|
|
att.close(websocket.CloseInternalServerErr, "write failed")
|
|
return
|
|
}
|
|
}
|
|
if len(data) == 0 {
|
|
return
|
|
}
|
|
if err := att.writeLocked(cmdMessage(data)); err != nil {
|
|
global.LOG.Errorf("ssh sending combo output to webSocket failed, err: %v", err)
|
|
att.close(websocket.CloseInternalServerErr, "write failed")
|
|
return
|
|
}
|
|
att.cursor = next
|
|
}
|
|
|
|
func (s *Session) keepaliveLoop() {
|
|
tick := time.NewTicker(keepaliveInterval)
|
|
defer tick.Stop()
|
|
for {
|
|
select {
|
|
case <-s.done:
|
|
return
|
|
case <-tick.C:
|
|
result := make(chan error, 1)
|
|
go func() { result <- s.backend.Keepalive() }()
|
|
select {
|
|
case err := <-result:
|
|
if err != nil {
|
|
global.LOG.Infof("terminal session %s keepalive failed: %v", s.ID, err)
|
|
s.Close()
|
|
return
|
|
}
|
|
case <-time.After(keepaliveTimeout):
|
|
global.LOG.Infof("terminal session %s keepalive timed out", s.ID)
|
|
s.Close()
|
|
return
|
|
case <-s.done:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Session) waitBackend() {
|
|
_ = s.backend.Wait()
|
|
s.flush()
|
|
s.Close()
|
|
}
|
|
|
|
func cmdMessage(data []byte) []byte {
|
|
msg, _ := json.Marshal(WsMsg{Type: WsMsgCmd, Data: base64.StdEncoding.EncodeToString(data)})
|
|
return msg
|
|
}
|
|
|
|
func sendClose(ws *websocket.Conn, code int, reason string) {
|
|
_ = ws.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(code, reason), time.Now().Add(time.Second))
|
|
}
|