package main import ( "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "golang.org/x/crypto/ssh" ) // ─── 持久化数据类型 ─────────────────────────────────────────────── type SSHProfile struct { Name string `json:"name,omitempty"` // 文件名(不含 .json) Alias string `json:"alias"` Host string `json:"host"` Port int `json:"port"` Username string `json:"username"` Password string `json:"password,omitempty"` PrivateKey string `json:"privateKey,omitempty"` Passphrase string `json:"passphrase,omitempty"` } type Command struct { Alias string `json:"alias"` Command string `json:"command"` } type ScriptInfo struct { Name string `json:"name"` Content string `json:"content,omitempty"` } // 配置与数据目录辅助函数见 config.go type wsMessage struct { Type string `json:"type"` Host string `json:"host,omitempty"` Port int `json:"port,omitempty"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` PrivateKey string `json:"privateKey,omitempty"` Passphrase string `json:"passphrase,omitempty"` Data string `json:"data,omitempty"` Cols int `json:"cols,omitempty"` Rows int `json:"rows,omitempty"` Status string `json:"status,omitempty"` Message string `json:"message,omitempty"` } type wsWriter struct { conn *websocket.Conn mu sync.Mutex } func (w *wsWriter) send(msg wsMessage) { w.mu.Lock() defer w.mu.Unlock() _ = w.conn.WriteJSON(msg) } func main() { if mode := os.Getenv("GIN_MODE"); mode != "" { gin.SetMode(mode) } router := gin.New() router.Use(gin.Logger(), gin.Recovery(), corsMiddleware()) allowedOrigins := parseListEnv("ALLOWED_ORIGINS") upgrader := websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { return isOriginAllowed(r.Header.Get("Origin"), allowedOrigins) }, } // ─── 基本配置 CRUD ────────────────────────────────────────── router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", "time": time.Now().Format(time.RFC3339), }) }) router.GET("/api/ws/ssh", func(c *gin.Context) { handleSSHWebSocket(c, upgrader) }) // ─── SSH 配置 CRUD ────────────────────────────────────────── router.GET("/api/ssh", handleListSSH) router.POST("/api/ssh", handleCreateSSH) router.PUT("/api/ssh/:name", handleUpdateSSH) router.DELETE("/api/ssh/:name", handleDeleteSSH) // ─── 快捷命令 CRUD ───────────────────────────────────────── router.GET("/api/commands", handleListCommands) router.POST("/api/commands", handleCreateCommand) router.PUT("/api/commands/:index", handleUpdateCommand) router.DELETE("/api/commands/:index", handleDeleteCommand) // ─── 脚本 CRUD ───────────────────────────────────────────── router.GET("/api/scripts", handleListScripts) router.GET("/api/scripts/:name", handleGetScript) router.POST("/api/scripts", handleCreateScript) router.PUT("/api/scripts/:name", handleUpdateScript) router.DELETE("/api/scripts/:name", handleDeleteScript) addr := getEnv("ADDR", ":"+getEnv("PORT", "8080")) server := &http.Server{ Addr: addr, Handler: router, ReadHeaderTimeout: 10 * time.Second, } log.Printf("SSH WebSocket server listening on %s", addr) if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("server error: %v", err) } } func handleSSHWebSocket(c *gin.Context, upgrader websocket.Upgrader) { conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { return } defer conn.Close() conn.SetReadLimit(1 << 20) writer := &wsWriter{conn: conn} writer.send(wsMessage{Type: "status", Status: "connected", Message: "WebSocket connected"}) var ( sshClient *ssh.Client sshSession *ssh.Session sshStdin io.WriteCloser stdout io.Reader stderr io.Reader cancelFn context.CancelFunc ) cleanup := func() { if cancelFn != nil { cancelFn() } if sshSession != nil { _ = sshSession.Close() } if sshClient != nil { _ = sshClient.Close() } } defer cleanup() for { var msg wsMessage if err := conn.ReadJSON(&msg); err != nil { writer.send(wsMessage{Type: "status", Status: "closed", Message: "WebSocket closed"}) return } switch msg.Type { case "connect": if sshSession != nil { writer.send(wsMessage{Type: "error", Message: "SSH session already exists"}) continue } client, session, stdin, out, errOut, err := startSSHSession(msg) if err != nil { writer.send(wsMessage{Type: "error", Message: err.Error()}) continue } sshClient = client sshSession = session sshStdin = stdin stdout = out stderr = errOut ctx, cancel := context.WithCancel(context.Background()) cancelFn = cancel go streamToWebSocket(ctx, writer, stdout) go streamToWebSocket(ctx, writer, stderr) go func() { _ = session.Wait() writer.send(wsMessage{Type: "status", Status: "closed", Message: "SSH session closed"}) cleanup() }() writer.send(wsMessage{Type: "status", Status: "ready", Message: "SSH connected"}) case "input": if sshStdin == nil { writer.send(wsMessage{Type: "error", Message: "SSH session not ready"}) continue } if msg.Data != "" { _, _ = sshStdin.Write([]byte(msg.Data)) } case "resize": if sshSession == nil { continue } rows := msg.Rows cols := msg.Cols if rows > 0 && cols > 0 { _ = sshSession.WindowChange(rows, cols) } case "ping": writer.send(wsMessage{Type: "pong"}) case "close": writer.send(wsMessage{Type: "status", Status: "closing", Message: "Closing SSH session"}) return } } } func startSSHSession(msg wsMessage) (*ssh.Client, *ssh.Session, io.WriteCloser, io.Reader, io.Reader, error) { host := strings.TrimSpace(msg.Host) if host == "" { return nil, nil, nil, nil, nil, errors.New("host is required") } port := msg.Port if port == 0 { port = 22 } user := strings.TrimSpace(msg.Username) if user == "" { return nil, nil, nil, nil, nil, errors.New("username is required") } auths, err := buildAuthMethods(msg) if err != nil { return nil, nil, nil, nil, nil, err } cfg := &ssh.ClientConfig{ User: user, Auth: auths, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 12 * time.Second, } addr := fmt.Sprintf("%s:%d", host, port) client, err := ssh.Dial("tcp", addr, cfg) if err != nil { return nil, nil, nil, nil, nil, fmt.Errorf("ssh dial failed: %w", err) } session, err := client.NewSession() if err != nil { _ = client.Close() return nil, nil, nil, nil, nil, fmt.Errorf("ssh session failed: %w", err) } rows := msg.Rows cols := msg.Cols if rows == 0 { rows = 24 } if cols == 0 { cols = 80 } modes := ssh.TerminalModes{ ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_OSPEED: 14400, } if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil { _ = session.Close() _ = client.Close() return nil, nil, nil, nil, nil, fmt.Errorf("request pty failed: %w", err) } stdin, err := session.StdinPipe() if err != nil { _ = session.Close() _ = client.Close() return nil, nil, nil, nil, nil, fmt.Errorf("stdin pipe failed: %w", err) } stdout, err := session.StdoutPipe() if err != nil { _ = session.Close() _ = client.Close() return nil, nil, nil, nil, nil, fmt.Errorf("stdout pipe failed: %w", err) } stderr, err := session.StderrPipe() if err != nil { _ = session.Close() _ = client.Close() return nil, nil, nil, nil, nil, fmt.Errorf("stderr pipe failed: %w", err) } if err := session.Shell(); err != nil { _ = session.Close() _ = client.Close() return nil, nil, nil, nil, nil, fmt.Errorf("shell start failed: %w", err) } return client, session, stdin, stdout, stderr, nil } func buildAuthMethods(msg wsMessage) ([]ssh.AuthMethod, error) { var methods []ssh.AuthMethod if strings.TrimSpace(msg.PrivateKey) != "" { signer, err := parsePrivateKey(msg.PrivateKey, msg.Passphrase) if err != nil { return nil, fmt.Errorf("private key error: %w", err) } methods = append(methods, ssh.PublicKeys(signer)) } if msg.Password != "" { methods = append(methods, ssh.Password(msg.Password)) } if len(methods) == 0 { return nil, errors.New("no auth method provided") } return methods, nil } func parsePrivateKey(key, passphrase string) (ssh.Signer, error) { key = strings.TrimSpace(key) if passphrase != "" { return ssh.ParsePrivateKeyWithPassphrase([]byte(key), []byte(passphrase)) } return ssh.ParsePrivateKey([]byte(key)) } func streamToWebSocket(ctx context.Context, writer *wsWriter, reader io.Reader) { buf := make([]byte, 8192) for { select { case <-ctx.Done(): return default: } n, err := reader.Read(buf) if n > 0 { writer.send(wsMessage{Type: "output", Data: string(buf[:n])}) } if err != nil { return } } } // CORS、中间件与环境变量工具函数见 config.go // ═══════════════════════════════════════════════════════════════════ // SSH 配置 CRUD // ═══════════════════════════════════════════════════════════════════ // GET /api/ssh — 列出所有 SSH 配置 func handleListSSH(c *gin.Context) { entries, err := os.ReadDir(sshDir()) if err != nil { c.JSON(http.StatusOK, gin.H{"data": []SSHProfile{}}) return } var profiles []SSHProfile for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { continue } raw, err := os.ReadFile(filepath.Join(sshDir(), e.Name())) if err != nil { continue } var p SSHProfile if err := json.Unmarshal(raw, &p); err != nil { continue } p.Name = strings.TrimSuffix(e.Name(), ".json") profiles = append(profiles, p) } if profiles == nil { profiles = []SSHProfile{} } c.JSON(http.StatusOK, gin.H{"data": profiles}) } // POST /api/ssh — 新建 SSH 配置 func handleCreateSSH(c *gin.Context) { var p SSHProfile if err := c.ShouldBindJSON(&p); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } if p.Alias == "" || p.Host == "" || p.Username == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "alias、host 和 username 为必填项"}) return } name := p.Name if name == "" { name = p.Alias } safe, err := sanitizeName(strings.ReplaceAll(name, " ", "-")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } p.Name = "" raw, _ := json.MarshalIndent(p, "", " ") if err := os.MkdirAll(sshDir(), 0o750); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dir"}) return } if err := os.WriteFile(filepath.Join(sshDir(), safe+".json"), raw, 0o600); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"}) return } p.Name = safe c.JSON(http.StatusOK, gin.H{"data": p}) } // PUT /api/ssh/:name — 更新 SSH 配置 func handleUpdateSSH(c *gin.Context) { name, err := sanitizeName(c.Param("name")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } var p SSHProfile if err := c.ShouldBindJSON(&p); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } if p.Alias == "" || p.Host == "" || p.Username == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "alias、host 和 username 为必填项"}) return } p.Name = "" raw, _ := json.MarshalIndent(p, "", " ") filePath := filepath.Join(sshDir(), name+".json") if _, err := os.Stat(filePath); os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } if err := os.WriteFile(filePath, raw, 0o600); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"}) return } p.Name = name c.JSON(http.StatusOK, gin.H{"data": p}) } // DELETE /api/ssh/:name — 删除 SSH 配置 func handleDeleteSSH(c *gin.Context) { name, err := sanitizeName(c.Param("name")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } if err := os.Remove(filepath.Join(sshDir(), name+".json")); err != nil { if os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"}) } return } c.JSON(http.StatusOK, gin.H{"message": "deleted"}) } // ═══════════════════════════════════════════════════════════════════ // 快捷命令 CRUD // ═══════════════════════════════════════════════════════════════════ func readCommands() ([]Command, error) { raw, err := os.ReadFile(cmdFilePath()) if err != nil { if os.IsNotExist(err) { return []Command{}, nil } return nil, err } var cmds []Command if err := json.Unmarshal(raw, &cmds); err != nil { return nil, err } return cmds, nil } func writeCommands(cmds []Command) error { raw, err := json.MarshalIndent(cmds, "", " ") if err != nil { return err } if err := os.MkdirAll(filepath.Dir(cmdFilePath()), 0o750); err != nil { return err } return os.WriteFile(cmdFilePath(), raw, 0o600) } // GET /api/commands func handleListCommands(c *gin.Context) { cmds, err := readCommands() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"}) return } c.JSON(http.StatusOK, gin.H{"data": cmds}) } // POST /api/commands func handleCreateCommand(c *gin.Context) { var cmd Command if err := c.ShouldBindJSON(&cmd); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } if cmd.Alias == "" || cmd.Command == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "alias 和 command 为必填项"}) return } cmds, err := readCommands() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"}) return } cmds = append(cmds, cmd) if err := writeCommands(cmds); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save commands"}) return } c.JSON(http.StatusOK, gin.H{"data": cmds}) } // PUT /api/commands/:index func handleUpdateCommand(c *gin.Context) { idx, err := strconv.Atoi(c.Param("index")) if err != nil || idx < 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid index"}) return } var cmd Command if err := c.ShouldBindJSON(&cmd); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } if cmd.Alias == "" || cmd.Command == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "alias 和 command 为必填项"}) return } cmds, err := readCommands() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"}) return } if idx >= len(cmds) { c.JSON(http.StatusNotFound, gin.H{"error": "index out of range"}) return } cmds[idx] = cmd if err := writeCommands(cmds); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save commands"}) return } c.JSON(http.StatusOK, gin.H{"data": cmds}) } // DELETE /api/commands/:index func handleDeleteCommand(c *gin.Context) { idx, err := strconv.Atoi(c.Param("index")) if err != nil || idx < 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid index"}) return } cmds, err := readCommands() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"}) return } if idx >= len(cmds) { c.JSON(http.StatusNotFound, gin.H{"error": "index out of range"}) return } cmds = append(cmds[:idx], cmds[idx+1:]...) if err := writeCommands(cmds); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save commands"}) return } c.JSON(http.StatusOK, gin.H{"data": cmds}) } // ═══════════════════════════════════════════════════════════════════ // 脚本 CRUD // ═══════════════════════════════════════════════════════════════════ // GET /api/scripts — 列出所有脚本名称 func handleListScripts(c *gin.Context) { entries, err := os.ReadDir(scriptDir()) if err != nil { c.JSON(http.StatusOK, gin.H{"data": []ScriptInfo{}}) return } var scripts []ScriptInfo for _, e := range entries { if !e.IsDir() { scripts = append(scripts, ScriptInfo{Name: e.Name()}) } } if scripts == nil { scripts = []ScriptInfo{} } c.JSON(http.StatusOK, gin.H{"data": scripts}) } // GET /api/scripts/:name — 获取脚本内容 func handleGetScript(c *gin.Context) { name, err := sanitizeName(c.Param("name")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } raw, err := os.ReadFile(filepath.Join(scriptDir(), name)) if err != nil { if os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read"}) } return } c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: string(raw)}}) } // POST /api/scripts — 新建脚本 func handleCreateScript(c *gin.Context) { var s ScriptInfo if err := c.ShouldBindJSON(&s); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } if s.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name 为必填项"}) return } name, err := sanitizeName(s.Name) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } if err := os.MkdirAll(scriptDir(), 0o750); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dir"}) return } if err := os.WriteFile(filepath.Join(scriptDir(), name), []byte(s.Content), 0o640); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write"}) return } c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: s.Content}}) } // PUT /api/scripts/:name — 更新脚本内容 func handleUpdateScript(c *gin.Context) { name, err := sanitizeName(c.Param("name")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } filePath := filepath.Join(scriptDir(), name) if _, err := os.Stat(filePath); os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } var s ScriptInfo if err := c.ShouldBindJSON(&s); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } if err := os.WriteFile(filePath, []byte(s.Content), 0o640); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write"}) return } c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: s.Content}}) } // DELETE /api/scripts/:name — 删除脚本 func handleDeleteScript(c *gin.Context) { name, err := sanitizeName(c.Param("name")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } if err := os.Remove(filepath.Join(scriptDir(), name)); err != nil { if os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"}) } return } c.JSON(http.StatusOK, gin.H{"message": "deleted"}) }