2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 06:47:20 +08:00
Files
panel/pkg/shell/pty.go
2026-01-26 20:09:33 +08:00

167 lines
3.6 KiB
Go

package shell
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"syscall"
"time"
"github.com/coder/websocket"
"github.com/creack/pty"
)
// MessageResize 终端大小调整消息
type MessageResize struct {
Resize bool `json:"resize"`
Columns uint `json:"columns"`
Rows uint `json:"rows"`
}
// MessagePing ping 消息
type MessagePing struct {
Ping bool `json:"ping"`
}
// Turn PTY 终端
type Turn struct {
ctx context.Context
ws *websocket.Conn
ptmx *os.File
cmd *exec.Cmd
}
// NewPTYTurn 使用 PTY 执行命令,返回 Turn 用于流式读取输出
// 调用方需要负责调用 Close() 和 Wait()
func NewPTYTurn(ctx context.Context, ws *websocket.Conn, shell string, args ...any) (*Turn, error) {
if !preCheckArg(args) {
return nil, errors.New("command contains illegal characters")
}
if len(args) > 0 {
shell = fmt.Sprintf(shell, args...)
}
_ = os.Setenv("LC_ALL", "C")
cmd := exec.CommandContext(ctx, "bash", "-c", shell)
ptmx, err := pty.Start(cmd)
if err != nil {
return nil, fmt.Errorf("failed to start pty: %w", err)
}
return &Turn{
ctx: ctx,
ws: ws,
ptmx: ptmx,
cmd: cmd,
}, nil
}
// Write 写入 PTY 输入
func (t *Turn) Write(data []byte) (int, error) {
return t.ptmx.Write(data)
}
// Wait 等待命令完成
func (t *Turn) Wait() {
_ = t.cmd.Wait()
}
// Close 关闭 PTY 并终止子进程
func (t *Turn) Close() {
_ = t.cmd.Process.Signal(syscall.SIGTERM)
// 等待 10 秒
time.Sleep(10 * time.Second)
_ = t.cmd.Process.Kill()
_ = t.ptmx.Close()
}
// Handle 从 WebSocket 读取输入写入 PTY
func (t *Turn) Handle(ctx context.Context) error {
var resize MessageResize
var ping MessagePing
go func() { _ = t.Pipe(ctx) }()
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
_, data, err := t.ws.Read(ctx)
if err != nil {
// 通常是客户端关闭连接
return fmt.Errorf("failed to read ws message: %w", err)
}
// 判断是否是 ping 消息
if err = json.Unmarshal(data, &ping); err == nil && ping.Ping {
_ = t.ws.Write(ctx, websocket.MessageText, []byte(`{"pong":true}`))
continue
}
// 判断是否是 resize 消息
if err = json.Unmarshal(data, &resize); err == nil {
if resize.Resize && resize.Columns > 0 && resize.Rows > 0 {
if err = t.Resize(uint16(resize.Rows), uint16(resize.Columns)); err != nil {
return fmt.Errorf("failed to resize terminal: %w", err)
}
}
continue
}
if _, err = t.Write(data); err != nil {
return fmt.Errorf("failed to write to pty stdin: %w", err)
}
}
}
}
// Pipe 从 PTY 读取输出写入 WebSocket
func (t *Turn) Pipe(ctx context.Context) error {
buf := make([]byte, 8192)
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
n, err := t.ptmx.Read(buf)
if err != nil {
if err = IsPTYError(err); err != nil {
return fmt.Errorf("failed to read from pty: %w", err)
}
return nil
}
if n > 0 {
if err = t.ws.Write(ctx, websocket.MessageBinary, buf[:n]); err != nil {
return fmt.Errorf("failed to write to ws: %w", err)
}
}
}
}
}
// Resize 调整 PTY 窗口大小
func (t *Turn) Resize(rows, cols uint16) error {
return pty.Setsize(t.ptmx, &pty.Winsize{
Rows: rows,
Cols: cols,
})
}
// IsPTYError Linux kernel return EIO when attempting to read from a master pseudo
// terminal which no longer has an open slave. So ignore error here.
// See https://github.com/creack/pty/issues/21
func IsPTYError(err error) error {
var pathErr *os.PathError
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, syscall.EIO) || !errors.Is(err, io.EOF) {
return err
}
return nil
}