WebSSH遠端管理Linux伺服器、Web終端視窗自適應(二)

2023-07-18 09:00:31

上一篇:Gin+Xterm.js實現WebSSH遠端Kubernetes Pod

 

  • 支援使用者名稱密碼認證

  • 支援SSH金鑰認證

  • 支援Web終端視窗自適應

  • 支援錄屏審計

Go SSH

golang.org/x/crypto/ssh 是 Go 語言的一個庫,它提供了 SSH(Secure Shell)協定的實現,可以用來構建 SSH 使用者端和伺服器。

  • 安裝
go get golang.org/x/crypto/ssh
  • SSH基本範例
在建立SSH使用者端之前,首先需要建立一個ClientConfig物件,其中包含了進行SSH通訊所必須的設定資訊。
config := &ssh.ClientConfig{
    User: "username",
    Auth: []ssh.AuthMethod{
        ssh.Password("password"),
    },
    HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}

在上述程式碼中,我們設定了使用者名稱(User)、認證方式(Auth)和主機金鑰回撥(HostKeyCallback)。請注意,為了安全起見,在生產環境中不應使用InsecureIgnoreHostKey,而應使用更嚴格的主機金鑰檢查方式。

然後可以使用 ssh.Dial 函數來建立一個 SSH 使用者端連線:

client, err := ssh.Dial("tcp", "localhost:22", config)
if err != nil {
    log.Fatal("Failed to dial: ", err)
}

建立了 SSH 使用者端連線之後,我們就可以使用它來執行遠端命令。例如:

session, err := client.NewSession()
if err != nil {
    log.Fatal("Failed to create session: ", err)
}
defer session.Close()

out, err := session.CombinedOutput("ls")
if err != nil {
    log.Fatal("Failed to run command: ", err)
}

fmt.Println(string(out))

在這裡,我們首先使用 client.NewSession 方法建立了一個新的 SSH 對談。然後,我們使用 session.CombinedOutput 方法來執行遠端命令並獲取其輸出。

使用Gin、x/crypto/ssh 實現SSH

package main

import (
 "encoding/json"
 "fmt"
 "github.com/gin-gonic/gin"
 "github.com/gorilla/websocket"
 "golang.org/x/crypto/ssh"
 "log"
 "net/http"
 "os"
)

const (
 // 輸入訊息
 messageTypeInput = "input"
 // 調整視窗大小訊息
 messageTypeResize = "resize"
 // 金鑰認證方式
 authTypeKey = "key"
 // 密碼認證方式
 authTypePwd = "pwd"
)

// websocket 連線升級
var upgrader = websocket.Upgrader{
 CheckOrigin: func(r *http.Request) bool {
  return true
 },
}

// WSClient WebSocket使用者端存取物件,包含WebSocket連線物件和SSH對談物件
type WSClient struct {
 // WebSocket 連線物件
 ws         *websocket.Conn
 sshSession *ssh.Session
}

// Message 用於解析從websocket接收到的json訊息
type Message struct {
 Type string `json:"type"`
 Cols int    `json:"cols"`
 Rows int    `json:"rows"`
 Text string `json:"text"`
}

// WSClient 的 Read 方法,實現了 io.Reader 介面,從 websocket 中讀取資料。
func (c *WSClient) Read(p []byte) (n int, err error) {
 // 從 WebSocket 中讀取訊息
 _, message, err := c.ws.ReadMessage()
 if err != nil {
  return 0, err
 }
 msg := &Message{}
 if err := json.Unmarshal(message, msg); err != nil {
  return 0, err
 }

 switch msg.Type {
 case messageTypeInput:
  // 如果是輸入訊息
  return copy(p, msg.Text), err
 case messageTypeResize:
  // 如果是視窗調整訊息、調整視窗大小
  return 0, c.WindowChange(msg.Rows, msg.Cols)
 default:
  return 0, fmt.Errorf("invalid message type")
 }
}

// WindowChange 改變SSH Session視窗大小
func (c *WSClient) WindowChange(rows, cols int) error {
 return c.sshSession.WindowChange(rows, cols)
}

// WSClient 的 Write 方法,實現了 io.Writer 介面,將資料寫入 websocket。
func (c *WSClient) Write(p []byte) (n int, err error) {
 // 將資料作為文字訊息寫入 WebSocket
 err = c.ws.WriteMessage(websocket.TextMessage, p)
 return len(p), err
}

// 建立SSH Client
func sshDial(user, password, ip, authType string, port int) (*ssh.Client, error) {
 var authMethods []ssh.AuthMethod
 // 根據認證型別選擇金鑰或密碼認證
 switch authType {
 case authTypeKey:
  privateKeyByte, err := os.ReadFile("./id_rsa")
  if err != nil {
   return nil, err
  }
  privateKey, err := ssh.ParsePrivateKey(privateKeyByte)
  if err != nil {
   return nil, err
  }
  authMethods = append(authMethods, ssh.PublicKeys(privateKey))

 case authTypePwd:
  authMethods = append(authMethods, ssh.Password(password))
 }
 // SSH client設定
 config := &ssh.ClientConfig{
  User:            user,
  Auth:            authMethods,
  HostKeyCallback: ssh.InsecureIgnoreHostKey(),
 }
 // 建立SSH client
 return ssh.Dial("tcp", fmt.Sprintf("%s:%d", ip, port), config)
}

// SSHHandler 處理SSH對談
func SSHHandler(wsClient *WSClient, user, password, ip, authType, command string, port int) {
 // 建立SSH client
 sshClient, err := sshDial(user, password, ip, authType, port)
 if err != nil {
  log.Fatal(err)
 }
 defer sshClient.Close()

 // 建立SSH session
 session, err := sshClient.NewSession()
 if err != nil {
  log.Fatal(err)
 }
 defer session.Close()

 wsClient.sshSession = session
 // 設定終端型別及大小
 terminalModes := ssh.TerminalModes{
  ssh.ECHO:          1,
  ssh.TTY_OP_ISPEED: 14400,
  ssh.TTY_OP_OSPEED: 14400,
 }
 if err := session.RequestPty("xterm", 24, 80, terminalModes); err != nil {
  log.Fatal(err)
 }
 // 關聯對應輸入、輸出流
 session.Stderr = wsClient
 session.Stdout = wsClient
 session.Stdin = wsClient
 // 在遠端執行命令
 if err := session.Run(command); err != nil {
  log.Fatal(err)
 }

}

// Query 查詢引數
type Query struct {
 UserName string `form:"username" binding:"required"`
 Password string `form:"password"`
 IP       string `form:"ip" binding:"required"`
 Port     int    `form:"port" binding:"required"`
 AuthType string `form:"auth_type" binding:"required,oneof=key pwd"`
 Command  string `form:"command" binding:"required,oneof=sh bash"`
}

func main() {
 router := gin.Default()
 router.GET("/ssh", func(ctx *gin.Context) {
  var r Query
  // 繫結並校驗請求引數
  if err := ctx.ShouldBindQuery(&r); err != nil {
   ctx.JSON(http.StatusBadRequest, gin.H{
    "err": err.Error(),
   })
   return
  }
  // 將 HTTP 連線升級為 websocket 連線
  ws, err := upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
  if err != nil {
   log.Printf("Failed to upgrade connection: %v", err)
   return
  }
  // 開始處理 SSH 對談
  SSHHandler(&WSClient{
   ws: ws,
  }, r.UserName, r.Password, r.IP, r.AuthType, r.Command, r.Port,
  )
 })

 router.Run(":9191")
}

後端專案完整程式碼:https://gitee.com/KubeSec/webssh/tree/master/go-ssh

使用vue-admin-template和Xterm.js實現Web終端

https://github.com/PanJiaChen/vue-admin-template

https://github.com/xtermjs/xterm.js

  • 下載vue-admin-template專案

https://github.com/PanJiaChen/vue-admin-template.git

  • 安裝xterm.js及外掛

npm install
npm install xterm
npm install --save xterm-addon-web-links
npm install --save xterm-addon-fit
npm install -S xterm-style
  • 開啟vue-admin-template專案,在src/views目錄下新建目錄ssh,在ssh目錄下新建index.vue程式碼如下

<template>
  <div class="app-container">
    <!-- 使用 Element UI 的表單元件建立一個帶有標籤和輸入框的表單 -->
    <el-form ref="form" :model="form" :inline="true" label-width="120px">
      <el-form-item label="使用者名稱"> <!-- namespace 輸入框 -->
        <el-input v-model="form.username" />
      </el-form-item>
      <el-form-item label="密碼"> <!-- pod 名稱輸入框 -->
        <el-input v-model="form.password" />
      </el-form-item>
      <el-form-item label="IP"> <!-- pod 名稱輸入框 -->
        <el-input v-model="form.ip" />
      </el-form-item>
      <el-form-item label="埠"> <!-- pod 名稱輸入框 -->
        <el-input v-model="form.port" />
      </el-form-item>
      <el-form-item label="認證型別"> <!-- 容器名稱輸入框 -->
        <el-select v-model="form.auth_type" placeholder="認證型別">
          <el-option label="金鑰" value="key" />
          <el-option label="密碼" value="pwd" />
        </el-select>
      </el-form-item>
      <el-form-item label="Command"> <!-- 命令選擇框 -->
        <el-select v-model="form.command" placeholder="bash">
          <el-option label="bash" value="bash" />
          <el-option label="sh" value="sh" />
        </el-select>
      </el-form-item>
      <el-form-item> <!-- 提交按鈕 -->
        <el-button type="primary" @click="onSubmit">SSH</el-button>
      </el-form-item>
      <div id="terminal" /> <!-- 終端檢視容器 -->
    </el-form>
  </div>
</template>

<script>
import { Terminal } from 'xterm' // 匯入 xterm 包,用於建立和操作終端物件
import { common as xtermTheme } from 'xterm-style' // 匯入 xterm 樣式主題
import 'xterm/css/xterm.css' // 匯入 xterm CSS 樣式
import { FitAddon } from 'xterm-addon-fit' // 匯入 xterm fit 外掛,用於調整終端大小
import { WebLinksAddon } from 'xterm-addon-web-links' // 匯入 xterm web-links 外掛,可以捕獲 URL 並將其轉換為可點選連結
import 'xterm/lib/xterm.js' // 匯入 xterm 庫

export default {
  data() {
    return {
      form: {
        username: 'root', // 預設名稱空間為 "default"
        password: '123', // 預設 shell 命令為 "bash"
        command: 'bash', // 預設 shell 命令為 "bash"
        auth_type: 'pwd', // 預設容器名稱為 "nginx"
        ip: '192.168.26.133',
        port: 22
      }
    }
  },
  methods: {
    onSubmit() {
      // 建立一個新的 Terminal 物件
      const xterm = new Terminal({
        theme: xtermTheme,
        rendererType: 'canvas',
        convertEol: true,
        cursorBlink: true
      })

      // 建立並載入 FitAddon 和 WebLinksAddon
      const fitAddon = new FitAddon()
      xterm.loadAddon(fitAddon)
      xterm.loadAddon(new WebLinksAddon())

      // 開啟這個終端,並附加到 HTML 元素上
      xterm.open(document.getElementById('terminal'))

      // 調整終端的大小以適應其父元素
      fitAddon.fit()

      // 建立一個新的 WebSocket 連線,並通過 URL 引數傳遞 pod, namespace, container 和 command 資訊
      const ws = new WebSocket('ws://127.0.0.1:9191/ssh?username=' + this.form.username + '&password=' + this.form.password + '&auth_type=' + this.form.auth_type + '&ip=' + this.form.ip + '&port=' + this.form.port + '&command=' + this.form.command)

      // 當 WebSocket 連線開啟時,傳送一個 resize 訊息給伺服器,告訴它終端的尺寸
      ws.onopen = function() {
        ws.send(JSON.stringify({
          type: 'resize',
          rows: xterm.rows,
          cols: xterm.cols
        }))
      }

      // 當從伺服器收到訊息時,寫入終端顯示
      ws.onmessage = function(evt) {
        xterm.write(evt.data)
      }

      // 當發生錯誤時,也寫入終端顯示
      ws.onerror = function(evt) {
        xterm.write(evt.data)
      }

      // 當視窗尺寸變化時,重新調整終端的尺寸,並行送一個新的 resize 訊息給伺服器
      window.addEventListener('resize', function() {
        fitAddon.fit()
        ws.send(JSON.stringify({
          type: 'resize',
          rows: xterm.rows,
          cols: xterm.cols
        }))
      })

      // 當在終端中鍵入字元時,傳送一個 input 訊息給伺服器
      xterm.onData((b) => {
        ws.send(JSON.stringify({
          type: 'input',
          text: b
        }))
      })
    }
  }
}
</script>

<style scoped>
.line{
  text-align: center;
}
</style>
  • 在src/router/index.js檔案中增加路由

{
    path: '/ssh',
    component: Layout,
    children: [
      {
        path: 'ssh',
        name: 'SSH',
        component: () => import('@/views/ssh/index'),
        meta: { title: 'SSH', icon: 'form' }
      }
    ]
  },
  • 啟動專案

npm install
npm run dev
  • 前端全部程式碼

https://gitee.com/KubeSec/webssh/tree/master/webssh

測試

  • 生成SSH密碼

ssh-keygen -t rsa
cd /root/.ssh/
cp id_rsa.pub authorized_keys

存取http://localhost:9528/#/ssh/ssh

  • 選擇金鑰連線

  • 使用者名稱密碼連線