package main import ( "context" "crypto/rand" "database/sql" "encoding/hex" "errors" "fmt" "io" "log" "net/http" "os" "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"` 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 / db.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) } // ─── 会话令牌(服务启动时随机生成一次)──────────────────────────────── var sessionToken string func initSessionToken() { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { log.Fatalf("failed to generate session token: %v", err) } sessionToken = hex.EncodeToString(b) } // authMiddleware 校验请求中携带的令牌。 // WebSocket 不支持自定义 Header,因此同时接受 ?token= 查询参数。 func authMiddleware() gin.HandlerFunc { return func(c *gin.Context) { token := "" if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") { token = strings.TrimPrefix(auth, "Bearer ") } if token == "" { token = c.Query("token") } if token != sessionToken { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } c.Next() } } // POST /api/auth/login — 用密码换取令牌 func handleLogin(c *gin.Context) { var body struct { Password string `json:"password"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } if body.Password != accessPassword() { c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"}) return } c.JSON(http.StatusOK, gin.H{"data": gin.H{"token": sessionToken}}) } // GET /api/auth/verify — 校验令牌是否有效(受 authMiddleware 保护) func handleVerify(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": gin.H{"valid": true}}) } // GET / — 服务说明(无需鉴权,便于网关 / 探活) func handleAPIRoot(c *gin.Context) { loc := time.FixedZone("CST", 8*3600) c.JSON(http.StatusOK, gin.H{ "description": "提供 Web SSH 终端与跳板连接(SSH 由服务端发起);浏览器访问需先 POST /api/auth/login 换取令牌,后续 API 与 WebSocket 携带 Bearer 或 ?token=", "endpoints": gin.H{ "health": "/health", "auth": "/api/auth/login (POST,公开), /api/auth/verify (GET,需令牌)", "websocket_ssh": "/api/ws/ssh (GET 升级 WebSocket,需令牌;查询参数 token= 或 Header)", "ssh_profiles": "/api/ssh, /api/ssh/:name (CRUD,需令牌)", "commands": "/api/commands, /api/commands/:index (CRUD,需令牌)", "scripts": "/api/scripts, /api/scripts/:name (CRUD,需令牌)", }, "message": "萌芽 SSH MengyaConnect 后端 API 服务运行中", "timestamp": time.Now().In(loc).Format(time.RFC3339), "version": apiVersion(), }) } func main() { initSessionToken() if err := initDB(); err != nil { log.Fatalf("db init: %v", err) } migrateFromFiles() 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) }, } // ─── 不需要鉴权的路由 ──────────────────────────────────────── router.GET("/", handleAPIRoot) router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", "time": time.Now().Format(time.RFC3339), }) }) router.POST("/api/auth/login", handleLogin) // ─── 需要鉴权的路由 ────────────────────────────────────────── auth := router.Group("/api", authMiddleware()) auth.GET("/auth/verify", handleVerify) auth.GET("/ws/ssh", func(c *gin.Context) { handleSSHWebSocket(c, upgrader) }) // SSH 配置 CRUD auth.GET("/ssh", handleListSSH) auth.POST("/ssh", handleCreateSSH) auth.PUT("/ssh/:name", handleUpdateSSH) auth.DELETE("/ssh/:name", handleDeleteSSH) // 快捷命令 CRUD auth.GET("/commands", handleListCommands) auth.POST("/commands", handleCreateCommand) auth.PUT("/commands/:index", handleUpdateCommand) auth.DELETE("/commands/:index", handleDeleteCommand) // 脚本 CRUD auth.GET("/scripts", handleListScripts) auth.GET("/scripts/:name", handleGetScript) auth.POST("/scripts", handleCreateScript) auth.PUT("/scripts/:name", handleUpdateScript) auth.DELETE("/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) log.Printf("Access password configured (use ACCESS_PASSWORD env to override)") 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 } } } // ═══════════════════════════════════════════════════════════════════ // SSH 配置 CRUD // ═══════════════════════════════════════════════════════════════════ // GET /api/ssh func handleListSSH(c *gin.Context) { rows, err := DB.Query( `SELECT name,alias,host,port,username,password,private_key,passphrase FROM ssh_profiles ORDER BY id`) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db query failed"}) return } defer rows.Close() profiles := []SSHProfile{} for rows.Next() { var p SSHProfile if err := rows.Scan(&p.Name, &p.Alias, &p.Host, &p.Port, &p.Username, &p.Password, &p.PrivateKey, &p.Passphrase); err != nil { continue } profiles = append(profiles, p) } c.JSON(http.StatusOK, gin.H{"data": profiles}) } // POST /api/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 := strings.TrimSpace(p.Name) if name == "" { name = p.Alias } name = strings.ReplaceAll(name, " ", "-") if err := validateName(name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } if p.Port == 0 { p.Port = 22 } _, err := DB.Exec( `INSERT INTO ssh_profiles (name,alias,host,port,username,password,private_key,passphrase) VALUES (?,?,?,?,?,?,?,?)`, name, p.Alias, p.Host, p.Port, p.Username, p.Password, p.PrivateKey, p.Passphrase, ) if err != nil { if strings.Contains(err.Error(), "UNIQUE") { c.JSON(http.StatusConflict, gin.H{"error": "name already exists"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "db insert failed"}) } return } p.Name = name c.JSON(http.StatusOK, gin.H{"data": p}) } // PUT /api/ssh/:name func handleUpdateSSH(c *gin.Context) { name := c.Param("name") if err := validateName(name); 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 } if p.Port == 0 { p.Port = 22 } res, err := DB.Exec( `UPDATE ssh_profiles SET alias=?,host=?,port=?,username=?,password=?,private_key=?,passphrase=?, updated_at=CURRENT_TIMESTAMP WHERE name=?`, p.Alias, p.Host, p.Port, p.Username, p.Password, p.PrivateKey, p.Passphrase, name, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"}) return } if n, _ := res.RowsAffected(); n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } p.Name = name c.JSON(http.StatusOK, gin.H{"data": p}) } // DELETE /api/ssh/:name func handleDeleteSSH(c *gin.Context) { name := c.Param("name") if err := validateName(name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } res, err := DB.Exec(`DELETE FROM ssh_profiles WHERE name=?`, name) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db delete failed"}) return } if n, _ := res.RowsAffected(); n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "deleted"}) } // ═══════════════════════════════════════════════════════════════════ // 快捷命令 CRUD // ═══════════════════════════════════════════════════════════════════ func queryAllCommands() ([]Command, error) { rows, err := DB.Query(`SELECT alias,command FROM commands ORDER BY sort_order, id`) if err != nil { return nil, err } defer rows.Close() cmds := []Command{} for rows.Next() { var cmd Command if err := rows.Scan(&cmd.Alias, &cmd.Command); err != nil { return nil, err } cmds = append(cmds, cmd) } return cmds, nil } // commandIDAtIndex returns the primary-key id of the command at 0-based position idx. func commandIDAtIndex(idx int) (int64, error) { rows, err := DB.Query(`SELECT id FROM commands ORDER BY sort_order, id`) if err != nil { return 0, err } defer rows.Close() pos := 0 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return 0, err } if pos == idx { return id, nil } pos++ } return 0, sql.ErrNoRows } // GET /api/commands func handleListCommands(c *gin.Context) { cmds, err := queryAllCommands() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db query failed"}) 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 } var maxOrder int _ = DB.QueryRow(`SELECT COALESCE(MAX(sort_order),0) FROM commands`).Scan(&maxOrder) if _, err := DB.Exec( `INSERT INTO commands (alias,command,sort_order) VALUES (?,?,?)`, cmd.Alias, cmd.Command, maxOrder+1, ); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db insert failed"}) return } cmds, _ := queryAllCommands() 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 } id, err := commandIDAtIndex(idx) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "index out of range"}) return } if _, err := DB.Exec(`UPDATE commands SET alias=?,command=? WHERE id=?`, cmd.Alias, cmd.Command, id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"}) return } cmds, _ := queryAllCommands() 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 } id, err := commandIDAtIndex(idx) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "index out of range"}) return } if _, err := DB.Exec(`DELETE FROM commands WHERE id=?`, id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db delete failed"}) return } cmds, _ := queryAllCommands() c.JSON(http.StatusOK, gin.H{"data": cmds}) } // ═══════════════════════════════════════════════════════════════════ // 脚本 CRUD // ═══════════════════════════════════════════════════════════════════ // GET /api/scripts func handleListScripts(c *gin.Context) { rows, err := DB.Query(`SELECT name FROM scripts ORDER BY name`) if err != nil { c.JSON(http.StatusOK, gin.H{"data": []ScriptInfo{}}) return } defer rows.Close() scripts := []ScriptInfo{} for rows.Next() { var s ScriptInfo if err := rows.Scan(&s.Name); err != nil { continue } scripts = append(scripts, s) } c.JSON(http.StatusOK, gin.H{"data": scripts}) } // GET /api/scripts/:name func handleGetScript(c *gin.Context) { name := c.Param("name") if err := validateName(name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } var s ScriptInfo err := DB.QueryRow(`SELECT name,content FROM scripts WHERE name=?`, name). Scan(&s.Name, &s.Content) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } else if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db query failed"}) return } c.JSON(http.StatusOK, gin.H{"data": s}) } // 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 } if err := validateName(s.Name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } _, err := DB.Exec(`INSERT INTO scripts (name,content) VALUES (?,?)`, s.Name, s.Content) if err != nil { if strings.Contains(err.Error(), "UNIQUE") { c.JSON(http.StatusConflict, gin.H{"error": "name already exists"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "db insert failed"}) } return } c.JSON(http.StatusOK, gin.H{"data": s}) } // PUT /api/scripts/:name func handleUpdateScript(c *gin.Context) { name := c.Param("name") if err := validateName(name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } var s ScriptInfo if err := c.ShouldBindJSON(&s); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } res, err := DB.Exec( `UPDATE scripts SET content=?,updated_at=CURRENT_TIMESTAMP WHERE name=?`, s.Content, name, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"}) return } if n, _ := res.RowsAffected(); n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: s.Content}}) } // DELETE /api/scripts/:name func handleDeleteScript(c *gin.Context) { name := c.Param("name") if err := validateName(name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } res, err := DB.Exec(`DELETE FROM scripts WHERE name=?`, name) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db delete failed"}) return } if n, _ := res.RowsAffected(); n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "deleted"}) }