- Add proper disposal tracking to prevent memory leaks - Move terminal buffer from localStorage to sessionStorage - Restore buffer immediately after first WS message - Fix clear detection logic and error handling - Add signal parameter support for abortable fetch requests 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
342 lines
8.0 KiB
Go
342 lines
8.0 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/creack/pty/v2"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/muyue/muyue/internal/config"
|
|
)
|
|
|
|
var upgrader = websocket.Upgrader{
|
|
CheckOrigin: func(r *http.Request) bool {
|
|
origin := r.Header.Get("Origin")
|
|
if origin == "" {
|
|
return true
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(origin, "http://127.0.0.1"),
|
|
strings.HasPrefix(origin, "http://localhost"),
|
|
strings.HasPrefix(origin, "http://[::1]"),
|
|
strings.HasPrefix(origin, "https://127.0.0.1"),
|
|
strings.HasPrefix(origin, "https://localhost"),
|
|
strings.HasPrefix(origin, "https://[::1]"):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
},
|
|
}
|
|
|
|
type wsMessage struct {
|
|
Type string `json:"type"`
|
|
Data string `json:"data"`
|
|
Rows uint16 `json:"rows,omitempty"`
|
|
Cols uint16 `json:"cols,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
log.Printf("ws upgrade: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
var initMsg wsMessage
|
|
_, raw, err := conn.ReadMessage()
|
|
if err != nil {
|
|
log.Printf("terminal: read init message failed: %v", err)
|
|
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
|
|
return
|
|
}
|
|
log.Printf("terminal: init message received: %s", string(raw))
|
|
if err := json.Unmarshal(raw, &initMsg); err != nil {
|
|
log.Printf("terminal: unmarshal init message failed: %v", err)
|
|
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
|
|
return
|
|
}
|
|
log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data)
|
|
|
|
var cmd *exec.Cmd
|
|
|
|
if initMsg.Type == "ssh" && initMsg.Data != "" {
|
|
var sshConf struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
User string `json:"user"`
|
|
KeyPath string `json:"key_path"`
|
|
}
|
|
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
|
|
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
|
|
return
|
|
}
|
|
if sshConf.Port == 0 {
|
|
sshConf.Port = 22
|
|
}
|
|
|
|
sshArgs := []string{
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", "UserKnownHostsFile=/dev/null",
|
|
"-o", "LogLevel=ERROR",
|
|
}
|
|
if sshConf.KeyPath != "" {
|
|
sshArgs = append(sshArgs, "-i", sshConf.KeyPath)
|
|
}
|
|
if sshConf.Port != 22 {
|
|
sshArgs = append(sshArgs, "-p", fmt.Sprintf("%d", sshConf.Port))
|
|
}
|
|
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
|
|
|
|
cmd = exec.Command("ssh", sshArgs...)
|
|
} else {
|
|
shell := strings.TrimSpace(initMsg.Data)
|
|
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
|
|
if shell == "" {
|
|
shell = detectShell()
|
|
log.Printf("terminal: auto-detected shell=%q", shell)
|
|
}
|
|
|
|
if shell == "" {
|
|
log.Printf("terminal: no shell detected, falling back to /bin/sh")
|
|
shell = "/bin/sh"
|
|
}
|
|
|
|
if path, err := exec.LookPath(shell); err == nil {
|
|
shell = path
|
|
log.Printf("terminal: resolved shell path=%q", shell)
|
|
}
|
|
|
|
if _, err := os.Stat(shell); err != nil {
|
|
log.Printf("terminal: shell stat failed: %v for %q", err, shell)
|
|
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
|
|
return
|
|
}
|
|
|
|
shellName := filepath.Base(shell)
|
|
switch shellName {
|
|
case "wsl":
|
|
cmd = exec.Command(shell, "--shell-type", "login")
|
|
case "powershell", "pwsh":
|
|
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
|
|
case "fish":
|
|
cmd = exec.Command(shell, "--login")
|
|
default:
|
|
cmd = exec.Command(shell)
|
|
}
|
|
}
|
|
|
|
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
|
|
|
log.Printf("terminal: starting pty with cmd=%q args=%v", cmd.Path, cmd.Args)
|
|
ptmx, err := pty.Start(cmd)
|
|
if err != nil {
|
|
log.Printf("terminal: pty start failed: %v", err)
|
|
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
|
return
|
|
}
|
|
log.Printf("terminal: pty started successfully")
|
|
|
|
var once sync.Once
|
|
cleanup := func() {
|
|
once.Do(func() {
|
|
ptmx.Close()
|
|
if cmd.Process != nil {
|
|
cmd.Process.Kill()
|
|
cmd.Wait()
|
|
}
|
|
})
|
|
}
|
|
defer cleanup()
|
|
|
|
go func() {
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := ptmx.Read(buf)
|
|
if err != nil {
|
|
cleanup()
|
|
return
|
|
}
|
|
if err := conn.WriteJSON(wsMessage{
|
|
Type: "output",
|
|
Data: string(buf[:n]),
|
|
}); err != nil {
|
|
cleanup()
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
conn.SetReadLimit(1 << 20)
|
|
conn.SetReadDeadline(time.Time{})
|
|
|
|
for {
|
|
_, raw, err := conn.ReadMessage()
|
|
if err != nil {
|
|
cleanup()
|
|
return
|
|
}
|
|
|
|
var msg wsMessage
|
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
|
continue
|
|
}
|
|
|
|
switch msg.Type {
|
|
case "input":
|
|
if _, err := ptmx.Write([]byte(msg.Data)); err != nil {
|
|
cleanup()
|
|
return
|
|
}
|
|
case "resize":
|
|
if msg.Rows > 0 && msg.Cols > 0 {
|
|
pty.Setsize(ptmx, &pty.Winsize{
|
|
Rows: msg.Rows,
|
|
Cols: msg.Cols,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "GET" {
|
|
writeJSON(w, map[string]interface{}{
|
|
"ssh": s.config.Terminal.SSH,
|
|
"system": detectSystemTerminals(),
|
|
})
|
|
return
|
|
}
|
|
if r.Method != "POST" {
|
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
User string `json:"user"`
|
|
KeyPath string `json:"key_path"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if body.Name == "" || body.Host == "" {
|
|
writeError(w, "name and host required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if body.Port == 0 {
|
|
body.Port = 22
|
|
}
|
|
|
|
conn := config.SSHConnection{
|
|
Name: body.Name,
|
|
Host: body.Host,
|
|
Port: body.Port,
|
|
User: body.User,
|
|
KeyPath: body.KeyPath,
|
|
}
|
|
if s.config.Terminal.SSH == nil {
|
|
s.config.Terminal.SSH = []config.SSHConnection{}
|
|
}
|
|
s.config.Terminal.SSH = append(s.config.Terminal.SSH, conn)
|
|
if err := config.Save(s.config); err != nil {
|
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "DELETE" {
|
|
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
name := strings.TrimPrefix(r.URL.Path, "/api/terminal/sessions/")
|
|
if name == "" {
|
|
writeError(w, "name required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
found := false
|
|
for i, c := range s.config.Terminal.SSH {
|
|
if c.Name == name {
|
|
s.config.Terminal.SSH = append(s.config.Terminal.SSH[:i], s.config.Terminal.SSH[i+1:]...)
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
writeError(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err := config.Save(s.config); err != nil {
|
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func detectShell() string {
|
|
shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"}
|
|
for _, s := range shells {
|
|
if path, err := exec.LookPath(s); err == nil {
|
|
return path
|
|
}
|
|
}
|
|
return "/bin/sh"
|
|
}
|
|
|
|
func detectSystemTerminals() []map[string]string {
|
|
var terminals []map[string]string
|
|
|
|
terminals = append(terminals, map[string]string{
|
|
"type": "local",
|
|
"name": "Default Shell",
|
|
"shell": detectShell(),
|
|
})
|
|
|
|
if runtime.GOOS == "windows" {
|
|
if _, err := exec.LookPath("wsl"); err == nil {
|
|
terminals = append(terminals, map[string]string{
|
|
"type": "local",
|
|
"name": "WSL",
|
|
"shell": "wsl",
|
|
})
|
|
}
|
|
if _, err := exec.LookPath("powershell"); err == nil {
|
|
terminals = append(terminals, map[string]string{
|
|
"type": "local",
|
|
"name": "PowerShell",
|
|
"shell": "powershell",
|
|
})
|
|
}
|
|
if _, err := exec.LookPath("pwsh"); err == nil {
|
|
terminals = append(terminals, map[string]string{
|
|
"type": "local",
|
|
"name": "PowerShell Core",
|
|
"shell": "pwsh",
|
|
})
|
|
}
|
|
if _, err := exec.LookPath("cmd"); err == nil {
|
|
terminals = append(terminals, map[string]string{
|
|
"type": "local",
|
|
"name": "Command Prompt",
|
|
"shell": "cmd",
|
|
})
|
|
}
|
|
}
|
|
|
|
return terminals
|
|
}
|