0xJacky преди 2 години
родител
ревизия
101c374b9c

+ 7 - 3
frontend/package.json

@@ -1,9 +1,9 @@
 {
     "name": "nginx-ui-frontend",
-    "version": "1.3.2",
+    "version": "1.4.0",
     "private": true,
     "scripts": {
-        "serve": "vue-cli-service serve",
+        "serve": "vue-cli-service serve --port 8021",
         "build": "vue-cli-service build --dest dist --modern",
         "lint": "vue-cli-service lint"
     },
@@ -26,6 +26,7 @@
         "core-js": "^3.9.0",
         "less": "^3.11.1",
         "less-loader": "^5.0.0",
+        "lodash": "^4.17.21",
         "lowlight": "^1.20.0",
         "moment": "^2.24.0",
         "node-sass": "^6.0.1",
@@ -46,7 +47,10 @@
         "vue-template-compiler": "^2.6.11",
         "vue2-ace-editor": "^0.0.15",
         "vuex": "^3.6.2",
-        "vuex-persist": "^3.1.3"
+        "vuex-persist": "^3.1.3",
+        "xterm": "^4.19.0",
+        "xterm-addon-attach": "^0.6.0",
+        "xterm-addon-fit": "^0.5.0"
     },
     "devDependencies": {
         "@vue/cli-plugin-babel": "~4.5.15",

+ 12 - 8
frontend/src/locale/en/LC_MESSAGES/app.po

@@ -10,11 +10,11 @@ msgstr ""
 "Generated-By: easygettext\n"
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 
-#: src/router/index.js:99
+#: src/router/index.js:107
 msgid "404 Not Found"
 msgstr ""
 
-#: src/router/index.js:77
+#: src/router/index.js:85
 msgid "About"
 msgstr ""
 
@@ -119,7 +119,7 @@ msgstr ""
 msgid "Destroy"
 msgstr ""
 
-#: src/router/index.js:125
+#: src/router/index.js:133
 msgid "Detected version update, this page will refresh."
 msgstr ""
 
@@ -244,7 +244,7 @@ msgstr ""
 msgid "Index (index)"
 msgstr ""
 
-#: src/router/index.js:87 src/views/other/Install.vue:51
+#: src/router/index.js:95 src/views/other/Install.vue:51
 msgid "Install"
 msgstr ""
 
@@ -269,7 +269,7 @@ msgstr ""
 msgid "Load Averages:"
 msgstr ""
 
-#: src/router/index.js:93 src/views/other/Login.vue:25
+#: src/router/index.js:101 src/views/other/Login.vue:25
 msgid "Login"
 msgstr ""
 
@@ -339,7 +339,7 @@ msgstr ""
 msgid "No, I'm rethink"
 msgstr ""
 
-#: src/router/index.js:105
+#: src/router/index.js:113
 msgid "Not Found"
 msgstr ""
 
@@ -353,7 +353,7 @@ msgid ""
 "you need to get the certificate."
 msgstr ""
 
-#: src/router/index.js:129
+#: src/router/index.js:137
 msgid "OK"
 msgstr ""
 
@@ -460,10 +460,14 @@ msgstr ""
 msgid "Swap"
 msgstr ""
 
-#: src/router/index.js:124
+#: src/router/index.js:132
 msgid "System message"
 msgstr ""
 
+#: src/router/index.js:77
+msgid "Terminal"
+msgstr ""
+
 #: src/views/domain/columns.js:50
 msgid ""
 "The certificate for the domain will be checked every hour, and will be "

BIN
frontend/src/locale/zh_CN/LC_MESSAGES/app.mo


+ 14 - 10
frontend/src/locale/zh_CN/LC_MESSAGES/app.po

@@ -12,11 +12,11 @@ msgstr ""
 "Generated-By: easygettext\n"
 "X-Generator: Poedit 3.0.1\n"
 
-#: src/router/index.js:99
+#: src/router/index.js:107
 msgid "404 Not Found"
 msgstr "404 未找到页面"
 
-#: src/router/index.js:77
+#: src/router/index.js:85
 msgid "About"
 msgstr "关于"
 
@@ -121,7 +121,7 @@ msgstr "数据库 (可选,默认: database)"
 msgid "Destroy"
 msgstr "删除"
 
-#: src/router/index.js:125
+#: src/router/index.js:133
 msgid "Detected version update, this page will refresh."
 msgstr "检测到版本更新,页面将会刷新。"
 
@@ -246,7 +246,7 @@ msgstr "HTTPS 监听端口"
 msgid "Index (index)"
 msgstr "网站首页 (index)"
 
-#: src/router/index.js:87 src/views/other/Install.vue:51
+#: src/router/index.js:95 src/views/other/Install.vue:51
 msgid "Install"
 msgstr "安装"
 
@@ -271,7 +271,7 @@ msgstr "开源许可"
 msgid "Load Averages:"
 msgstr "系统负载:"
 
-#: src/router/index.js:93 src/views/other/Login.vue:25
+#: src/router/index.js:101 src/views/other/Login.vue:25
 msgid "Login"
 msgstr "登录"
 
@@ -343,7 +343,7 @@ msgstr "下一步"
 msgid "No, I'm rethink"
 msgstr "再想想"
 
-#: src/router/index.js:105
+#: src/router/index.js:113
 msgid "Not Found"
 msgstr "找不到页面"
 
@@ -357,7 +357,7 @@ msgid ""
 "you need to get the certificate."
 msgstr "注意:当前配置中的 server_name 必须为需要申请证书的域名。"
 
-#: src/router/index.js:129
+#: src/router/index.js:137
 msgid "OK"
 msgstr "确定"
 
@@ -464,10 +464,14 @@ msgstr "主体名称: %{name}"
 msgid "Swap"
 msgstr ""
 
-#: src/router/index.js:124
+#: src/router/index.js:132
 msgid "System message"
 msgstr "系统消息"
 
+#: src/router/index.js:77
+msgid "Terminal"
+msgstr "终端"
+
 #: src/views/domain/columns.js:50
 msgid ""
 "The certificate for the domain will be checked every hour, and will be "
@@ -488,8 +492,8 @@ msgid ""
 "fields in your configuration file. The configuration filename cannot be "
 "changed after it has been created."
 msgstr ""
-"只有在您的配置文件中有相应字段时,下列的配置才能生效。配置文件名称创建后不"
-"修改。"
+"只有在您的配置文件中有相应字段时,下列的配置才能生效。配置文件名称创建后不"
+"修改。"
 
 #: src/views/domain/DomainAdd.vue:15 src/views/domain/DomainAdd.vue:4
 #: src/views/domain/DomainEdit.vue:24 src/views/domain/DomainEdit.vue:5

BIN
frontend/src/locale/zh_TW/LC_MESSAGES/app.mo


+ 14 - 10
frontend/src/locale/zh_TW/LC_MESSAGES/app.po

@@ -13,11 +13,11 @@ msgstr ""
 "Generated-By: easygettext\n"
 "X-Generator: Poedit 3.0.1\n"
 
-#: src/router/index.js:99
+#: src/router/index.js:107
 msgid "404 Not Found"
 msgstr "404 未找到頁面"
 
-#: src/router/index.js:77
+#: src/router/index.js:85
 msgid "About"
 msgstr "關於"
 
@@ -122,7 +122,7 @@ msgstr "資料庫 (可選,預設: database)"
 msgid "Destroy"
 msgstr "删除"
 
-#: src/router/index.js:125
+#: src/router/index.js:133
 msgid "Detected version update, this page will refresh."
 msgstr "檢測到版本更新,頁面將會重新整理。"
 
@@ -247,7 +247,7 @@ msgstr "HTTPS 監聽埠"
 msgid "Index (index)"
 msgstr "網站首頁 (index)"
 
-#: src/router/index.js:87 src/views/other/Install.vue:51
+#: src/router/index.js:95 src/views/other/Install.vue:51
 msgid "Install"
 msgstr "安裝"
 
@@ -272,7 +272,7 @@ msgstr "開源許可"
 msgid "Load Averages:"
 msgstr "系統負載:"
 
-#: src/router/index.js:93 src/views/other/Login.vue:25
+#: src/router/index.js:101 src/views/other/Login.vue:25
 msgid "Login"
 msgstr "登入"
 
@@ -344,7 +344,7 @@ msgstr "下一步"
 msgid "No, I'm rethink"
 msgstr "再想想"
 
-#: src/router/index.js:105
+#: src/router/index.js:113
 msgid "Not Found"
 msgstr "找不到頁面"
 
@@ -358,7 +358,7 @@ msgid ""
 "you need to get the certificate."
 msgstr "注意:當前配置中的 server_name 必須為需要申請證書的域名。"
 
-#: src/router/index.js:129
+#: src/router/index.js:137
 msgid "OK"
 msgstr "確定"
 
@@ -465,10 +465,14 @@ msgstr "主體名稱: %{name}"
 msgid "Swap"
 msgstr "交換空間"
 
-#: src/router/index.js:124
+#: src/router/index.js:132
 msgid "System message"
 msgstr "系統訊息"
 
+#: src/router/index.js:77
+msgid "Terminal"
+msgstr "终端"
+
 #: src/views/domain/columns.js:50
 msgid ""
 "The certificate for the domain will be checked every hour, and will be "
@@ -489,8 +493,8 @@ msgid ""
 "fields in your configuration file. The configuration filename cannot be "
 "changed after it has been created."
 msgstr ""
-"只有在您的配置檔案中有相應欄位時,下列的配置才能生效。配置檔名稱建立後不可"
-"改。"
+"只有在您的配置檔案中有相應欄位時,下列的配置才能生效。配置檔名稱建立後不可"
+"改。"
 
 #: src/views/domain/DomainAdd.vue:15 src/views/domain/DomainAdd.vue:4
 #: src/views/domain/DomainEdit.vue:24 src/views/domain/DomainEdit.vue:5

+ 8 - 0
frontend/src/router/index.js

@@ -72,6 +72,14 @@ export const routes = [
                     hiddenInSidebar: true
                 },
             },
+            {
+                path: 'terminal',
+                name: $gettext('Terminal'),
+                component: () => import('@/views/pty/Terminal'),
+                meta: {
+                    icon: 'code'
+                }
+            },
             {
                 path: 'about',
                 name: $gettext('About'),

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
frontend/src/translations.json


+ 98 - 0
frontend/src/views/pty/Terminal.vue

@@ -0,0 +1,98 @@
+<template>
+    <a-card :title="$gettext('Terminal')">
+        <div class="console" id="terminal"></div>
+    </a-card>
+
+</template>
+
+<script>
+import ReconnectingWebSocket from 'reconnecting-websocket'
+import 'xterm/css/xterm.css'
+import {Terminal} from 'xterm'
+import {FitAddon} from 'xterm-addon-fit'
+
+const _ = require('lodash')
+
+export default {
+    name: 'Terminal',
+    data() {
+        return {
+            term: null,
+            ping: null
+        }
+    },
+    created() {
+        this.websocket = new ReconnectingWebSocket(this.getWebSocketRoot() + '/pty?token='
+            + btoa(this.$store.state.user.token))
+        this.websocket.onmessage = this.wsOnMessage
+        this.websocket.onopen = this.wsOnOpen
+    },
+    mounted() {
+        this.initTerm()
+    },
+    destroyed() {
+        window.removeEventListener('resize', this.fit)
+        clearInterval(this.ping)
+        this.ping = null
+        this.websocket.close()
+    },
+    methods: {
+        fit: _.throttle(function () {
+            this.fitAddon.fit()
+        }, 50),
+        initTerm() {
+            const term = new Terminal({
+                rendererType: 'canvas',
+                convertEol: true,
+                fontSize: 14,
+                cursorStyle: 'block',
+                scrollback: 30,
+            })
+            const fitAddon = new FitAddon()
+            term.loadAddon(fitAddon)
+            this.fitAddon = fitAddon
+            term.open(document.getElementById('terminal'))
+            setTimeout(()=>{
+                fitAddon.fit()
+            }, 60)
+            window.addEventListener('resize', this.fit)
+            term.focus()
+
+            let that = this
+
+            term.onData(function (key) {
+                let order = {
+                    Data: key,
+                    Type: 1
+                }
+                that.sendMessage(order)
+            })
+            term.onBinary(data => {
+                that.sendMessage({Type: 1, Data: data})
+            })
+            term.onResize(data => {
+                that.sendMessage({Type:2, Data:{Cols:data.cols, Rows: data.rows}})
+            })
+            this.term = term
+        },
+        wsOnMessage(msg) {
+            this.term.write(msg.data)
+        },
+        wsOnOpen() {
+            const that = this
+            this.ping = setInterval(function () {
+                that.sendMessage({Type: 3})
+            }, 10000)
+        },
+        sendMessage(data) {
+            this.websocket.send(JSON.stringify(data))
+        }
+    }
+}
+</script>
+
+<style lang="less" scoped>
+.console {
+    min-height: 800px;
+}
+</style>

+ 16 - 1
frontend/yarn.lock

@@ -6963,7 +6963,7 @@ lodash.uniq@^4.5.0:
   resolved "https://registry.npm.taobao.org/lodash.uniq/download/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
   integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
 
-lodash@4.17.21, lodash@^4.0.0, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.3, lodash@^4.17.5, lodash@~4.17.10:
+lodash@4.17.21, lodash@^4.0.0, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.3, lodash@^4.17.5, lodash@~4.17.10:
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -11196,6 +11196,21 @@ xtend@^4.0.0, xtend@~4.0.1:
   resolved "https://registry.npm.taobao.org/xtend/download/xtend-4.0.2.tgz?cache=0&sync_timestamp=1589682817913&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fxtend%2Fdownload%2Fxtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
   integrity sha1-u3J3n1+kZRhrH0OPZ0+jR/2121Q=
 
+xterm-addon-attach@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/xterm-addon-attach/-/xterm-addon-attach-0.6.0.tgz#220c23addd62ab88c9914e2d4c06f7407e44680e"
+  integrity sha512-Mo8r3HTjI/EZfczVCwRU6jh438B4WLXxdFO86OB7bx0jGhwh2GdF4ifx/rP+OB+Cb2vmLhhVIZ00/7x3YSP3dg==
+
+xterm-addon-fit@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz#2d51b983b786a97dcd6cde805e700c7f913bc596"
+  integrity sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ==
+
+xterm@^4.19.0:
+  version "4.19.0"
+  resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.19.0.tgz#c0f9d09cd61de1d658f43ca75f992197add9ef6d"
+  integrity sha512-c3Cp4eOVsYY5Q839dR5IejghRPpxciGmLWWaP9g+ppfMeBChMeLa1DCA+pmX/jyDZ+zxFOmlJL/82qVdayVoGQ==
+
 y18n@^4.0.0:
   version "4.0.1"
   resolved "https://registry.npm.taobao.org/y18n/download/y18n-4.0.1.tgz?cache=0&sync_timestamp=1609798661541&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fy18n%2Fdownload%2Fy18n-4.0.1.tgz#8db2b83c31c5d75099bb890b23f3094891e247d4"

+ 1 - 0
go.mod

@@ -27,6 +27,7 @@ require (
 require (
 	github.com/StackExchange/wmi v1.2.1 // indirect
 	github.com/cenkalti/backoff/v4 v4.1.0 // indirect
+	github.com/creack/pty v1.1.18 // indirect
 	github.com/gin-contrib/sse v0.1.0 // indirect
 	github.com/go-ole/go-ole v1.2.5 // indirect
 	github.com/golang/protobuf v1.3.4 // indirect

+ 2 - 0
go.sum

@@ -80,6 +80,8 @@ github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfc
 github.com/cpu/goacmedns v0.1.1/go.mod h1:MuaouqEhPAHxsbqjgnck5zeghuwBP1dLnPoobeGqugQ=
 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
+github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
 github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=

+ 46 - 0
server/api/pty.go

@@ -0,0 +1,46 @@
+package api
+
+import (
+	"github.com/0xJacky/Nginx-UI/server/tool/pty"
+	"github.com/gin-gonic/gin"
+	"github.com/gorilla/websocket"
+	"log"
+	"net/http"
+)
+
+func Pty(c *gin.Context) {
+	var upGrader = websocket.Upgrader{
+		CheckOrigin: func(r *http.Request) bool {
+			return true
+		},
+	}
+	// upgrade http to websocket
+	ws, err := upGrader.Upgrade(c.Writer, c.Request, nil)
+	if err != nil {
+		log.Println("pty ws upgrade error", err)
+		return
+	}
+
+	defer ws.Close()
+
+	p, err := pty.NewPipeLine(ws)
+
+	if err != nil {
+		log.Println("pty.NewPipLine error", err)
+		return
+	}
+
+	defer p.Pty.Close()
+
+	errorChan := make(chan error, 1)
+	go p.ReadPtyAndWriteWs(errorChan)
+	go p.ReadWsAndWritePty(errorChan)
+
+	err = <-errorChan
+
+	if err != nil {
+		log.Println(err)
+	}
+
+	return
+}

+ 85 - 82
server/router/routers.go

@@ -1,89 +1,92 @@
 package router
 
 import (
-	"bufio"
-	"github.com/0xJacky/Nginx-UI/server/api"
-	"github.com/0xJacky/Nginx-UI/server/settings"
-	"github.com/gin-contrib/static"
-	"github.com/gin-gonic/gin"
-	"net/http"
-	"strings"
+    "bufio"
+    "github.com/0xJacky/Nginx-UI/server/api"
+    "github.com/0xJacky/Nginx-UI/server/settings"
+    "github.com/gin-contrib/static"
+    "github.com/gin-gonic/gin"
+    "net/http"
+    "strings"
 )
 
 func InitRouter() *gin.Engine {
-	r := gin.New()
-	r.Use(gin.Logger())
-
-	r.Use(recovery())
-
-	r.Use(cacheJs())
-
-	r.Use(static.Serve("/", mustFS("")))
-
-	r.NoRoute(func(c *gin.Context) {
-		accept := c.Request.Header.Get("Accept")
-		if strings.Contains(accept, "text/html") {
-			file, _ := mustFS("").Open("index.html")
-			defer file.Close()
-			stat, _ := file.Stat()
-			c.DataFromReader(http.StatusOK, stat.Size(), "text/html",
-				bufio.NewReader(file), nil)
-			return
-		}
-	})
-
-	g := r.Group("/api")
-	{
-
-		g.GET("settings", func(c *gin.Context) {
-			c.JSON(http.StatusOK, gin.H{
-				"demo": settings.ServerSettings.Demo,
-			})
-		})
-
-		g.GET("install", api.InstallLockCheck)
-		g.POST("install", api.InstallNginxUI)
-
-		g.POST("/login", api.Login)
-		g.DELETE("/logout", api.Logout)
-
-		g := g.Group("/", authRequired())
-		{
-			g.GET("/analytic", api.Analytic)
-			g.GET("/analytic/init", api.GetAnalyticInit)
-
-			g.GET("/users", api.GetUsers)
-			g.GET("/user/:id", api.GetUser)
-			g.POST("/user", api.AddUser)
-			g.POST("/user/:id", api.EditUser)
-			g.DELETE("/user/:id", api.DeleteUser)
-
-			g.GET("domains", api.GetDomains)
-			g.GET("domain/:name", api.GetDomain)
-			g.POST("domain/:name", api.EditDomain)
-			g.POST("domain/:name/enable", api.EnableDomain)
-			g.POST("domain/:name/disable", api.DisableDomain)
-			g.DELETE("domain/:name", api.DeleteDomain)
-
-			g.GET("configs", api.GetConfigs)
-			g.GET("config/:name", api.GetConfig)
-			g.POST("config", api.AddConfig)
-			g.POST("config/:name", api.EditConfig)
-
-			g.GET("backups", api.GetFileBackupList)
-			g.GET("backup/:id", api.GetFileBackup)
-
-			g.GET("template/:name", api.GetTemplate)
-
-			g.GET("cert/issue/:domain", api.IssueCert)
-			g.GET("cert/:domain/info", api.CertInfo)
-
-			// 添加域名到自动续期列表
-			g.POST("cert/:domain", api.AddDomainToAutoCert)
-			// 从自动续期列表中删除域名
-			g.DELETE("cert/:domain", api.RemoveDomainFromAutoCert)
-		}
-	}
-
-	return r
+    r := gin.New()
+    r.Use(gin.Logger())
+
+    r.Use(recovery())
+
+    r.Use(cacheJs())
+
+    r.Use(static.Serve("/", mustFS("")))
+
+    r.NoRoute(func(c *gin.Context) {
+        accept := c.Request.Header.Get("Accept")
+        if strings.Contains(accept, "text/html") {
+            file, _ := mustFS("").Open("index.html")
+            defer file.Close()
+            stat, _ := file.Stat()
+            c.DataFromReader(http.StatusOK, stat.Size(), "text/html",
+                bufio.NewReader(file), nil)
+            return
+        }
+    })
+
+    g := r.Group("/api")
+    {
+
+        g.GET("settings", func(c *gin.Context) {
+            c.JSON(http.StatusOK, gin.H{
+                "demo": settings.ServerSettings.Demo,
+            })
+        })
+
+        g.GET("install", api.InstallLockCheck)
+        g.POST("install", api.InstallNginxUI)
+
+        g.POST("/login", api.Login)
+        g.DELETE("/logout", api.Logout)
+
+        g := g.Group("/", authRequired())
+        {
+            g.GET("/analytic", api.Analytic)
+            g.GET("/analytic/init", api.GetAnalyticInit)
+
+            g.GET("/users", api.GetUsers)
+            g.GET("/user/:id", api.GetUser)
+            g.POST("/user", api.AddUser)
+            g.POST("/user/:id", api.EditUser)
+            g.DELETE("/user/:id", api.DeleteUser)
+
+            g.GET("domains", api.GetDomains)
+            g.GET("domain/:name", api.GetDomain)
+            g.POST("domain/:name", api.EditDomain)
+            g.POST("domain/:name/enable", api.EnableDomain)
+            g.POST("domain/:name/disable", api.DisableDomain)
+            g.DELETE("domain/:name", api.DeleteDomain)
+
+            g.GET("configs", api.GetConfigs)
+            g.GET("config/:name", api.GetConfig)
+            g.POST("config", api.AddConfig)
+            g.POST("config/:name", api.EditConfig)
+
+            g.GET("backups", api.GetFileBackupList)
+            g.GET("backup/:id", api.GetFileBackup)
+
+            g.GET("template/:name", api.GetTemplate)
+
+            g.GET("cert/issue/:domain", api.IssueCert)
+            g.GET("cert/:domain/info", api.CertInfo)
+
+            // 添加域名到自动续期列表
+            g.POST("cert/:domain", api.AddDomainToAutoCert)
+            // 从自动续期列表中删除域名
+            g.DELETE("cert/:domain", api.RemoveDomainFromAutoCert)
+
+            // pty
+            g.GET("pty", api.Pty)
+        }
+    }
+
+    return r
 }

+ 142 - 0
server/tool/pty/pipeline.go

@@ -0,0 +1,142 @@
+package pty
+
+import (
+	"encoding/json"
+	"github.com/creack/pty"
+	"github.com/gorilla/websocket"
+	"github.com/pkg/errors"
+	"os"
+	"os/exec"
+	"time"
+	"unicode/utf8"
+)
+
+type Pipeline struct {
+	Pty *os.File
+	ws  *websocket.Conn
+}
+
+type Message struct {
+	Type MsgType
+	Data json.RawMessage
+}
+
+const bufferSize = 2048
+
+func NewPipeLine(conn *websocket.Conn) (p *Pipeline, err error) {
+	c := exec.Command("login")
+
+	ptmx, err := pty.StartWithSize(c, &pty.Winsize{Cols: 90, Rows: 60})
+	if err != nil {
+		return nil, errors.Wrap(err, "start pty error")
+	}
+
+	p = &Pipeline{
+		Pty: ptmx,
+		ws:  conn,
+	}
+
+	return
+}
+
+func (p *Pipeline) ReadWsAndWritePty(errorChan chan error) {
+	for {
+		msgType, payload, err := p.ws.ReadMessage()
+		if err != nil {
+			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived,
+				websocket.CloseNormalClosure) {
+				errorChan <- errors.Wrap(err, "Error ReadWsAndWritePty unexpected close")
+			}
+			return
+		}
+		if msgType != websocket.TextMessage {
+			errorChan <- errors.Errorf("Error ReadWsAndWritePty Invalid msgType: %v", msgType)
+			return
+		}
+
+		var msg Message
+		err = json.Unmarshal(payload, &msg)
+		if err != nil {
+			errorChan <- errors.Wrap(err, "Error ReadWsAndWritePty json.Unmarshal")
+			return
+		}
+
+		switch msg.Type {
+		case TypeData:
+			var data string
+			err = json.Unmarshal(msg.Data, &data)
+			if err != nil {
+				errorChan <- errors.Wrap(err, "Error ReadWsAndWritePty json.Unmarshal msg.Data")
+				return
+			}
+
+			_, err = p.Pty.Write([]byte(data))
+
+			if err != nil {
+				errorChan <- errors.Wrap(err, "Error ReadWsAndWritePty write pty")
+				return
+			}
+		case TypeResize:
+			var win struct {
+				Cols uint16
+				Rows uint16
+			}
+
+			err = json.Unmarshal(msg.Data, &win)
+			if err != nil {
+				errorChan <- errors.Wrap(err, "Error ReadSktAndWritePty Invalid resize message")
+				return
+			}
+			err = pty.Setsize(p.Pty, &pty.Winsize{Rows: win.Rows, Cols: win.Cols})
+			if err != nil {
+				errorChan <- errors.Wrap(err, "Error ReadSktAndWritePty set pty size")
+				return
+			}
+		case TypePing:
+			err = p.ws.WriteControl(websocket.PongMessage, []byte{}, time.Now().Add(time.Second))
+			if err != nil {
+				errorChan <- errors.Wrap(err, "Error ReadSktAndWritePty write pong")
+				return
+			}
+		default:
+			errorChan <- errors.Errorf("Error ReadWsAndWritePty unknown msg.Type %v", msg.Type)
+			return
+		}
+	}
+}
+
+func (p *Pipeline) ReadPtyAndWriteWs(errorChan chan error) {
+	buf := make([]byte, bufferSize)
+	for {
+		n, err := p.Pty.Read(buf)
+		if err != nil {
+			errorChan <- errors.Wrap(err, "Error ReadPtyAndWriteWs read pty")
+			return
+		}
+		processedOutput := validString(string(buf[:n]))
+		err = p.ws.WriteMessage(websocket.TextMessage, []byte(processedOutput))
+		if err != nil {
+			if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure) {
+				errorChan <- errors.Wrap(err, "Error ReadPtyAndWriteWs websocket write")
+			}
+			return
+		}
+	}
+}
+
+func validString(s string) string {
+	if !utf8.ValidString(s) {
+		v := make([]rune, 0, len(s))
+		for i, r := range s {
+			if r == utf8.RuneError {
+				_, size := utf8.DecodeRuneInString(s[i:])
+				if size == 1 {
+					continue
+				}
+			}
+			v = append(v, r)
+		}
+		s = string(v)
+	}
+	return s
+}

+ 10 - 0
server/tool/pty/type.go

@@ -0,0 +1,10 @@
+package pty
+
+type MsgType int
+
+const (
+	MsgTypeInit MsgType = iota
+	TypeData
+	TypeResize
+	TypePing
+)

Някои файлове не бяха показани, защото твърде много файлове са промени