参考:https://github.com/leffss/gowebssh

一、架构设计

  • websocket简介:https://www.ruanyifeng.com/blog/2017/05/websocket.html
  • 前端通过websocket与后端GO程序进行连接,后端使用github.com/gorilla/websocket
  • GO程序通过golang.org/x/crypto/ssh包与远程node主机进行ssh连接
  • 后端GO程序将ssh会话的stdout,stderr通过websocket转发给前端,并将前端发送的stdin转发到远程ssh。

二、后端

后端代码地址:https://github.com/fanb129/Kube-CC/tree/main/service/ws

// WebSSH 管理 Websocket 和 ssh 连接
type WebSSH struct {
	id                               string
	buffSize                         uint32
	term                             string
	sshConn                          net.Conn
	websocket                        *websocket.Conn
	connTimeout                      time.Duration
	logger                           *log.Logger
	DisableZModemSZ, DisableZModemRZ bool
	ZModemSZ, ZModemRZ, ZModemSZOO   bool
}

// AddWebsocket 添加 websocket 连接
func (ws *WebSSH) AddWebsocket(conn *websocket.Conn) {}

// AddSSHConn 添加 ssh 连接
func (ws *WebSSH) AddSSHConn(conn net.Conn) {}

// 处理 websocket 连接发送过来的数据
func (ws *WebSSH) server() error {}

// 创建 ssh 会话
func (ws *WebSSH) newSSHXtermSession(conn net.Conn, config *ssh.ClientConfig, msg message) (*ssh.Session, error) {}

// 发送 ssh 会话的 stdout 和 stdin 数据到 websocket 连接
func (ws *WebSSH) transformOutput(session *ssh.Session, conn *websocket.Conn) error{}

路由监听

package ws

import (
	"fmt"
	"github.com/gorilla/websocket"
	"log"
	"net/http"
	"os"
	"time"
)

func NodeWsSsh(w http.ResponseWriter, r *http.Request) {
	id := r.Header.Get("Sec-WebSocket-Key")
	fmt.Println(id)
	webssh := NewWebSSH()
	// term 可以使用 ansi, linux, vt100, xterm, dumb,除了 dumb外其他都有颜色显示, 默认 xterm
	webssh.SetTerm(TermXterm)
	webssh.SetBuffSize(8192)
	webssh.SetId(id)
	webssh.SetConnTimeOut(5 * time.Second)
	webssh.SetLogger(log.New(os.Stderr, "[webssh] ", log.Ltime|log.Ldate))

	// 是否启用 sz 与 rz
	//webssh.DisableSZ()
	//webssh.DisableRZ()

	upGrader := websocket.Upgrader{
		// cross origin domain
		CheckOrigin: func(r *http.Request) bool {
			return true
		},
		// 处理 Sec-WebSocket-Protocol Header
		//Subprotocols: []string{r.Header.Get("Sec-WebSocket-Protocol")},
		Subprotocols:    []string{"webssh"},
		ReadBufferSize:  8192,
		WriteBufferSize: 8192,
	}

	ws, err := upGrader.Upgrade(w, r, nil)

	if err != nil {
		log.Panic(err)
	}

	//ws.SetCompressionLevel(4)
	//ws.EnableWriteCompression(true)

	webssh.AddWebsocket(ws)
}
r := gin.Default()

	r.Use(gin.Logger())             // 日志
	r.Use(middleware.CorsHandler()) // 跨域设置
	r.Use(gin.Recovery())           // 恐慌 恢复
	gin.SetMode(conf.AppMode)

	r.GET("/api/node/ssh", node.WsSsh)

三、前端

前端代码地址:https://github.com/fanb129/Kube-CC_vue/blob/master/src/components/Terminal/index.vue

使用vue

<template>
  <div>
    <el-tabs v-model="activeName" @tab-click="handleClick" style="margin-left: 1vh; margin-right: 1vh">
      <el-tab-pane name="first" label="SSH">
        <div style="text-align: center">
          <el-form ref="form" :model="form" status-icon :rules="rules" label-position="left" label-width="80px" style="margin-left: 50vh; width: 50vh">
            <el-form-item label="Ip" prop="ip">
              <el-input v-model="form.ip"/>
            </el-form-item>
            <el-form-item label="Port" prop="port">
              <el-input v-model="form.port"/>
            </el-form-item>
            <el-form-item label="User" prop="user">
              <el-input v-model="form.user"/>
            </el-form-item>
            <el-form-item label="Password" prop="pwd">
              <el-input v-model="form.pwd" type="password"/>
            </el-form-item>
            <el-form-item>
              <el-button type="primary" @click="submitForm('form')">连接</el-button>
              <el-button @click="resetForm('form')">重置</el-button>
            </el-form-item>
          </el-form>
        </div>
      </el-tab-pane>
      <el-tab-pane name="second" label="Terminal">
        <div class="ssh-container" ref="terminal"></div>
      </el-tab-pane>
    </el-tabs>
  </div>
</template>

<script>
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import 'xterm/css/xterm.css'
import { debounce } from 'lodash'

const packResize = (cols, rows) =>
  JSON.stringify({
    type: 'resize',
    cols: cols,
    rows: rows
  })
export default {
  name: 'MyTerminal',
  created() {
    this.form.ip = this.$route.query.ip || ''
    this.form.port = this.$route.query.port || ''
  },
  data() {
    var validate = (rule, value, callback) => {
      if (value === '') {
        callback(new Error('不能为空'));
      } else {
        callback();
      }
    };
    return {
      rules: {
        ip: [
          {validator: validate, trigger: 'blur'}
        ],
        port: [
          {validator: validate, trigger: 'blur'}
        ],
        user: [
          {validator: validate, trigger: 'blur'}
        ],
        pwd: [
          {validator: validate, trigger: 'blur'}
        ],
      },
      activeName: 'first',
      initText: '连接中...\r\n',
      first: true,
      term: null,
      fitAddon: null,
      ws: null,
      socketUrl: 'ws://127.0.0.1:8080/api/node/ssh',
      form: {
        user: '',
        pwd: '',
        ip: '',
        port: '',
      },
      option: {
        lineHeight: 1.0,
        cursorBlink: true,
        cursorStyle: 'block', // 光标样式 'block' | 'underline' | 'bar'
        fontSize: 18,
        fontFamily: "Monaco, Menlo, Consolas, 'Courier New', monospace",
        theme: {
          background: '#181d28'
        },
        cols: 30 // 初始化的时候不要设置fit,设置col为较小值(最小为可展示initText初始文字即可)方便屏幕缩放
      }
    }
  },
  // mounted() {
  //   this.initTerm()
  //   this.initSocket()
  //
  //   this.onTerminalResize()
  //   this.onTerminalKeyPress()
  // },
  beforeDestroy() {
    this.removeResizeListener()
    this.term && this.term.dispose()
  },
  methods: {
    initWs() {
      this.initTerm()
      this.initSocket()
      this.onTerminalResize()
      this.onTerminalKeyPress()
    },
    submitForm(formName) {
      this.$refs[formName].validate((valid) => {
        if (valid) {
          this.activeName = 'second'
          this.initWs()
        } else {
          console.log('error submit!!');
          return false;
        }
      });
    },
    resetForm(formName) {
      this.$refs[formName].resetFields();
    },
    handleClick(tab, event) {
      if (tab.name === 'second') {
        // this.init()
      }
    },
    utf8_to_b64(rawString) {
      return btoa(unescape(encodeURIComponent(rawString)));
    },
    b64_to_utf8(encodeString) {
      return decodeURIComponent(escape(atob(encodeString)));
    },
    bytesHuman(bytes, precision) {
      if (!/^([-+])?|(\.\d+)(\d+(\.\d+)?|(\d+\.)|Infinity)$/.test(bytes)) {
        return '-'
      }
      if (bytes === 0) return '0';
      if (typeof precision === 'undefined') precision = 1;
      const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'BB'];
      const num = Math.floor(Math.log(bytes) / Math.log(1024));
      const value = (bytes / Math.pow(1024, Math.floor(num))).toFixed(precision);
      return `${value} ${units[num]}`
    },
    isWsOpen() {
      return this.ws && this.ws.readyState === 1
    },
    initTerm() {
      this.term = new Terminal(this.option)
      this.fitAddon = new FitAddon()
      this.term.loadAddon(this.fitAddon)
      this.term.open(this.$refs.terminal)
      // this.fitAddon.fit() // 初始化的时候不要使用fit
      setTimeout(() => {
        this.fitAddon.fit()
      }, 500); // 必须延时处理
    },
    onTerminalKeyPress() {
      this.term.onData(data => {
        this.isWsOpen() && this.ws.send(JSON.stringify({
          type: 'stdin',
          data: this.utf8_to_b64(data)
        }))
      })
    },
    // resize 相关
    resizeRemoteTerminal() {
      const { cols, rows } = this.term
      console.log('列数、行数设置为:', cols, rows)
      // 调整后端终端大小 使后端与前端终端大小一致
      this.isWsOpen() && this.ws.send(packResize(cols, rows))
    },
    onResize: debounce(function () {
      this.fitAddon.fit()
    }, 500),
    onTerminalResize() {
      window.addEventListener('resize', this.onResize)
      this.term.onResize(this.resizeRemoteTerminal)
    },
    removeResizeListener() {
      window.removeEventListener('resize', this.onResize)
    },
    // socket
    initSocket() {
      this.term.write(this.initText)
      this.ws = new WebSocket(this.socketUrl,['webssh'])
      this.onOpenSocket()
      this.onCloseSocket()
      this.onErrorSocket()
      this.term._initialized = true
      this.onMessageSocket()
    },
    // 打开连接
    onOpenSocket() {
      this.ws.onopen = () => {
        console.log('websocket 已连接')
        this.ws.send(JSON.stringify({ type: "addr", data: this.utf8_to_b64(this.form.ip + ':' + this.form.port) }));
        // socket.send(JSON.stringify({ type: "term", data: utf8_to_b64("linux") }));
        this.ws.send(JSON.stringify({ type: "login", data: this.utf8_to_b64(this.form.user) }));
        this.ws.send(JSON.stringify({ type: "password", data: this.utf8_to_b64(this.form.pwd) }));
        this.term.reset()
        setTimeout(() => {
          this.resizeRemoteTerminal()
        }, 500)
      }
    },

    // 关闭连接
    onCloseSocket() {
      this.ws.onclose = () => {
        console.log('关闭连接')
        this.term.write("未连接, 刷新后重连...\r\n");
        // setTimeout(() => {
        //   this.initSocket();
        // }, 3000)
      }
    },
    // 连接错误
    onErrorSocket() {
      this.ws.onerror = () => {
        this.term.write('连接失败,请刷新!')
      }
    },
    // 接收消息
    onMessageSocket() {
      this.ws.onmessage = res => {
        console.log(res)
        const msg = JSON.parse(res.data)
        const term = this.term
        // console.log("receive: " + data)
        // 第一次连接成功将 initText 清空
        if (this.first) {
          this.first = false
          term.reset()
          term.element && term.focus()
          this.resizeRemoteTerminal()
        }
        term.write(this.b64_to_utf8(msg.data))
      }
    }
  }
}
//
</script>
<style lang="scss">
body {
  margin: 0;
  padding: 0;
}

.ssh-container {
  overflow: hidden;
  height: 85vh;
  border-radius: 4px;
  background: rgb(24, 29, 40);
  padding: 0px;
  color: rgb(255, 255, 255);

  .xterm-scroll-area::-webkit-scrollbar-thumb {
    background-color: #b7c4d1;
    /* 滚动条的背景颜色 */
  }
}
</style>

四、展示

  1. 可通过node页面跳转到Terminal页面,自动填写ip和port为当前选中node的ip:port
    在这里插入图片描述
    填写user和password后连接到node主机
    在这里插入图片描述
    在这里插入图片描述
  2. 也可直接打开Terminal页面,手动填写ip和port进行连接
    在这里插入图片描述
Logo

前往低代码交流专区

更多推荐