✨ v0.9.21 WebSSH
This commit is contained in:
parent
960266bf71
commit
9bf536b68a
@ -4,7 +4,7 @@
|
|||||||
<br>
|
<br>
|
||||||
<small><i>LOGO designed by <a href="https://xio.ng" target="_blank">熊大</a> .</i></small>
|
<small><i>LOGO designed by <a href="https://xio.ng" target="_blank">熊大</a> .</i></small>
|
||||||
<br><br>
|
<br><br>
|
||||||
<img src="https://img.shields.io/github/workflow/status/naiba/nezha/Dashboard%20image?label=Dash%20v0.9.20&logo=github&style=for-the-badge"> <img src="https://img.shields.io/github/v/release/naiba/nezha?color=brightgreen&label=Agent&style=for-the-badge&logo=github"> <img src="https://img.shields.io/github/workflow/status/naiba/nezha/Agent%20release?label=Agent%20CI&logo=github&style=for-the-badge"> <img src="https://img.shields.io/badge/Installer-v0.6.7-brightgreen?style=for-the-badge&logo=linux">
|
<img src="https://img.shields.io/github/workflow/status/naiba/nezha/Dashboard%20image?label=Dash%20v0.9.21&logo=github&style=for-the-badge"> <img src="https://img.shields.io/github/v/release/naiba/nezha?color=brightgreen&label=Agent&style=for-the-badge&logo=github"> <img src="https://img.shields.io/github/workflow/status/naiba/nezha/Agent%20release?label=Agent%20CI&logo=github&style=for-the-badge"> <img src="https://img.shields.io/badge/Installer-v0.6.7-brightgreen?style=for-the-badge&logo=linux">
|
||||||
<br>
|
<br>
|
||||||
<br>
|
<br>
|
||||||
<p>:trollface: <b>哪吒监控</b> 一站式轻监控轻运维系统。支持系统状态、HTTP(SSL 证书变更、即将到期、到期)、TCP、Ping 监控报警,命令批量执行和计划任务。</p>
|
<p>:trollface: <b>哪吒监控</b> 一站式轻监控轻运维系统。支持系统状态、HTTP(SSL 证书变更、即将到期、到期)、TCP、Ping 监控报警,命令批量执行和计划任务。</p>
|
||||||
|
@ -3,20 +3,27 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/genkiroid/cert"
|
"github.com/genkiroid/cert"
|
||||||
"github.com/go-ping/ping"
|
"github.com/go-ping/ping"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/kr/pty"
|
||||||
"github.com/p14yground/go-github-selfupdate/selfupdate"
|
"github.com/p14yground/go-github-selfupdate/selfupdate"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
@ -58,6 +65,13 @@ const (
|
|||||||
networkTimeOut = time.Second * 5 // 普通网络超时
|
networkTimeOut = time.Second * 5 // 普通网络超时
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type windowSize struct {
|
||||||
|
Rows uint16 `json:"rows"`
|
||||||
|
Cols uint16 `json:"cols"`
|
||||||
|
X uint16
|
||||||
|
Y uint16
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// 来自于 GoReleaser 的版本号
|
// 来自于 GoReleaser 的版本号
|
||||||
monitor.Version = version
|
monitor.Version = version
|
||||||
@ -154,7 +168,14 @@ func receiveTasks(tasks pb.NezhaService_RequestTaskClient) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
go doTask(task)
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if recover() != nil {
|
||||||
|
println("task panic", task)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
doTask(task)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,100 +184,16 @@ func doTask(task *pb.Task) {
|
|||||||
result.Id = task.GetId()
|
result.Id = task.GetId()
|
||||||
result.Type = task.GetType()
|
result.Type = task.GetType()
|
||||||
switch task.GetType() {
|
switch task.GetType() {
|
||||||
|
case model.TaskTypeTerminal:
|
||||||
|
handleTerminalTask(task)
|
||||||
case model.TaskTypeHTTPGET:
|
case model.TaskTypeHTTPGET:
|
||||||
start := time.Now()
|
handleHttpGetTask(task, &result)
|
||||||
resp, err := httpClient.Get(task.GetData())
|
|
||||||
if err == nil {
|
|
||||||
// 检查 HTTP Response 状态
|
|
||||||
result.Delay = float32(time.Since(start).Microseconds()) / 1000.0
|
|
||||||
if resp.StatusCode > 399 || resp.StatusCode < 200 {
|
|
||||||
err = errors.New("\n应用错误:" + resp.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
// 检查 SSL 证书信息
|
|
||||||
serviceUrl, err := url.Parse(task.GetData())
|
|
||||||
if err == nil {
|
|
||||||
if serviceUrl.Scheme == "https" {
|
|
||||||
c := cert.NewCert(serviceUrl.Host)
|
|
||||||
if c.Error != "" {
|
|
||||||
result.Data = "SSL证书错误:" + c.Error
|
|
||||||
} else {
|
|
||||||
result.Data = c.Issuer + "|" + c.NotAfter
|
|
||||||
result.Successful = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.Successful = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.Data = "URL解析错误:" + err.Error()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// HTTP 请求失败
|
|
||||||
result.Data = err.Error()
|
|
||||||
}
|
|
||||||
case model.TaskTypeICMPPing:
|
case model.TaskTypeICMPPing:
|
||||||
pinger, err := ping.NewPinger(task.GetData())
|
handleIcmpPingTask(task, &result)
|
||||||
if err == nil {
|
|
||||||
pinger.SetPrivileged(true)
|
|
||||||
pinger.Count = 5
|
|
||||||
pinger.Timeout = time.Second * 20
|
|
||||||
err = pinger.Run() // Blocks until finished.
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
result.Delay = float32(pinger.Statistics().AvgRtt.Microseconds()) / 1000.0
|
|
||||||
result.Successful = true
|
|
||||||
} else {
|
|
||||||
result.Data = err.Error()
|
|
||||||
}
|
|
||||||
case model.TaskTypeTCPPing:
|
case model.TaskTypeTCPPing:
|
||||||
start := time.Now()
|
handleTcpPingTask(task, &result)
|
||||||
conn, err := net.DialTimeout("tcp", task.GetData(), time.Second*10)
|
|
||||||
if err == nil {
|
|
||||||
conn.Write([]byte("ping\n"))
|
|
||||||
conn.Close()
|
|
||||||
result.Delay = float32(time.Since(start).Microseconds()) / 1000.0
|
|
||||||
result.Successful = true
|
|
||||||
} else {
|
|
||||||
result.Data = err.Error()
|
|
||||||
}
|
|
||||||
case model.TaskTypeCommand:
|
case model.TaskTypeCommand:
|
||||||
startedAt := time.Now()
|
handleCommandTask(task, &result)
|
||||||
var cmd *exec.Cmd
|
|
||||||
var endCh = make(chan struct{})
|
|
||||||
pg, err := utils.NewProcessExitGroup()
|
|
||||||
if err != nil {
|
|
||||||
// 进程组创建失败,直接退出
|
|
||||||
result.Data = err.Error()
|
|
||||||
client.ReportTask(context.Background(), &result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
timeout := time.NewTimer(time.Hour * 2)
|
|
||||||
if utils.IsWindows() {
|
|
||||||
cmd = exec.Command("cmd", "/c", task.GetData())
|
|
||||||
} else {
|
|
||||||
cmd = exec.Command("sh", "-c", task.GetData())
|
|
||||||
}
|
|
||||||
pg.AddProcess(cmd)
|
|
||||||
go func() {
|
|
||||||
select {
|
|
||||||
case <-timeout.C:
|
|
||||||
result.Data = "任务执行超时\n"
|
|
||||||
close(endCh)
|
|
||||||
pg.Dispose()
|
|
||||||
case <-endCh:
|
|
||||||
timeout.Stop()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
result.Data += fmt.Sprintf("%s\n%s", string(output), err.Error())
|
|
||||||
} else {
|
|
||||||
close(endCh)
|
|
||||||
result.Data = string(output)
|
|
||||||
result.Successful = true
|
|
||||||
}
|
|
||||||
result.Delay = float32(time.Since(startedAt).Seconds())
|
|
||||||
default:
|
default:
|
||||||
println("Unknown action: ", task)
|
println("Unknown action: ", task)
|
||||||
}
|
}
|
||||||
@ -307,6 +244,211 @@ func doSelfUpdate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleTcpPingTask(task *pb.Task, result *pb.TaskResult) {
|
||||||
|
start := time.Now()
|
||||||
|
conn, err := net.DialTimeout("tcp", task.GetData(), time.Second*10)
|
||||||
|
if err == nil {
|
||||||
|
conn.Write([]byte("ping\n"))
|
||||||
|
conn.Close()
|
||||||
|
result.Delay = float32(time.Since(start).Microseconds()) / 1000.0
|
||||||
|
result.Successful = true
|
||||||
|
} else {
|
||||||
|
result.Data = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIcmpPingTask(task *pb.Task, result *pb.TaskResult) {
|
||||||
|
pinger, err := ping.NewPinger(task.GetData())
|
||||||
|
if err == nil {
|
||||||
|
pinger.SetPrivileged(true)
|
||||||
|
pinger.Count = 5
|
||||||
|
pinger.Timeout = time.Second * 20
|
||||||
|
err = pinger.Run() // Blocks until finished.
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
result.Delay = float32(pinger.Statistics().AvgRtt.Microseconds()) / 1000.0
|
||||||
|
result.Successful = true
|
||||||
|
} else {
|
||||||
|
result.Data = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleHttpGetTask(task *pb.Task, result *pb.TaskResult) {
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := httpClient.Get(task.GetData())
|
||||||
|
if err == nil {
|
||||||
|
// 检查 HTTP Response 状态
|
||||||
|
result.Delay = float32(time.Since(start).Microseconds()) / 1000.0
|
||||||
|
if resp.StatusCode > 399 || resp.StatusCode < 200 {
|
||||||
|
err = errors.New("\n应用错误:" + resp.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
// 检查 SSL 证书信息
|
||||||
|
serviceUrl, err := url.Parse(task.GetData())
|
||||||
|
if err == nil {
|
||||||
|
if serviceUrl.Scheme == "https" {
|
||||||
|
c := cert.NewCert(serviceUrl.Host)
|
||||||
|
if c.Error != "" {
|
||||||
|
result.Data = "SSL证书错误:" + c.Error
|
||||||
|
} else {
|
||||||
|
result.Data = c.Issuer + "|" + c.NotAfter
|
||||||
|
result.Successful = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.Successful = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.Data = "URL解析错误:" + err.Error()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// HTTP 请求失败
|
||||||
|
result.Data = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCommandTask(task *pb.Task, result *pb.TaskResult) {
|
||||||
|
startedAt := time.Now()
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
var endCh = make(chan struct{})
|
||||||
|
pg, err := utils.NewProcessExitGroup()
|
||||||
|
if err != nil {
|
||||||
|
// 进程组创建失败,直接退出
|
||||||
|
result.Data = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timeout := time.NewTimer(time.Hour * 2)
|
||||||
|
if utils.IsWindows() {
|
||||||
|
cmd = exec.Command("cmd", "/c", task.GetData())
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("sh", "-c", task.GetData())
|
||||||
|
}
|
||||||
|
pg.AddProcess(cmd)
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-timeout.C:
|
||||||
|
result.Data = "任务执行超时\n"
|
||||||
|
close(endCh)
|
||||||
|
pg.Dispose()
|
||||||
|
case <-endCh:
|
||||||
|
timeout.Stop()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
result.Data += fmt.Sprintf("%s\n%s", string(output), err.Error())
|
||||||
|
} else {
|
||||||
|
close(endCh)
|
||||||
|
result.Data = string(output)
|
||||||
|
result.Successful = true
|
||||||
|
}
|
||||||
|
result.Delay = float32(time.Since(startedAt).Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleTerminalTask(task *pb.Task) {
|
||||||
|
var terminal model.TerminalTask
|
||||||
|
err := json.Unmarshal([]byte(task.GetData()), &terminal)
|
||||||
|
if err != nil {
|
||||||
|
println("Terminal 任务解析错误:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
protocol := "ws"
|
||||||
|
if terminal.UseSSL {
|
||||||
|
protocol += "s"
|
||||||
|
}
|
||||||
|
header := http.Header{}
|
||||||
|
header.Add("Secret", clientSecret)
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("%s://%s/terminal/%s", protocol, terminal.Host, terminal.Session), header)
|
||||||
|
if err != nil {
|
||||||
|
println("Terminal 连接失败:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
var shellPath string
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
shellPath, err = exec.LookPath("powershell.exe")
|
||||||
|
if err != nil || shellPath == "" {
|
||||||
|
shellPath = "cmd.exe"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shellPath = os.Getenv("SHELL")
|
||||||
|
if shellPath == "" {
|
||||||
|
shellPath = "sh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd = exec.Command(shellPath)
|
||||||
|
cmd.Env = append(os.Environ(), "TERM=xterm")
|
||||||
|
|
||||||
|
tty, err := pty.Start(cmd)
|
||||||
|
if err != nil {
|
||||||
|
println("Terminal pty.Start失败:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
cmd.Process.Kill()
|
||||||
|
cmd.Process.Wait()
|
||||||
|
tty.Close()
|
||||||
|
conn.Close()
|
||||||
|
println("terminal exit", terminal.Session)
|
||||||
|
}()
|
||||||
|
println("terminal init", terminal.Session, shellPath)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
read, err := tty.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
conn.WriteMessage(websocket.TextMessage, []byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.WriteMessage(websocket.BinaryMessage, buf[:read])
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
messageType, reader, err := conn.NextReader()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if messageType == websocket.TextMessage {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dataTypeBuf := make([]byte, 1)
|
||||||
|
read, err := reader.Read(dataTypeBuf)
|
||||||
|
if err != nil {
|
||||||
|
conn.WriteMessage(websocket.TextMessage, []byte("Unable to read message type from reader"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if read != 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch dataTypeBuf[0] {
|
||||||
|
case 0:
|
||||||
|
io.Copy(tty, reader)
|
||||||
|
case 1:
|
||||||
|
decoder := json.NewDecoder(reader)
|
||||||
|
resizeMessage := windowSize{}
|
||||||
|
err := decoder.Decode(&resizeMessage)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
syscall.Syscall(
|
||||||
|
syscall.SYS_IOCTL,
|
||||||
|
tty.Fd(),
|
||||||
|
syscall.TIOCSWINSZ,
|
||||||
|
uintptr(unsafe.Pointer(&resizeMessage)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func println(v ...interface{}) {
|
func println(v ...interface{}) {
|
||||||
if debug {
|
if debug {
|
||||||
log.Println(v...)
|
log.Println(v...)
|
||||||
|
@ -1,22 +1,37 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/hashicorp/go-uuid"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"github.com/naiba/nezha/model"
|
"github.com/naiba/nezha/model"
|
||||||
"github.com/naiba/nezha/pkg/mygin"
|
"github.com/naiba/nezha/pkg/mygin"
|
||||||
|
"github.com/naiba/nezha/proto"
|
||||||
"github.com/naiba/nezha/service/dao"
|
"github.com/naiba/nezha/service/dao"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type terminalContext struct {
|
||||||
|
agentConn *websocket.Conn
|
||||||
|
userConn *websocket.Conn
|
||||||
|
serverID uint64
|
||||||
|
host string
|
||||||
|
useSSL bool
|
||||||
|
}
|
||||||
|
|
||||||
type commonPage struct {
|
type commonPage struct {
|
||||||
r *gin.Engine
|
r *gin.Engine
|
||||||
|
terminals map[string]*terminalContext
|
||||||
|
terminalsLock *sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cp *commonPage) serve() {
|
func (cp *commonPage) serve() {
|
||||||
@ -27,6 +42,8 @@ func (cp *commonPage) serve() {
|
|||||||
cr.GET("/", cp.home)
|
cr.GET("/", cp.home)
|
||||||
cr.GET("/service", cp.service)
|
cr.GET("/service", cp.service)
|
||||||
cr.GET("/ws", cp.ws)
|
cr.GET("/ws", cp.ws)
|
||||||
|
cr.POST("/terminal", cp.createTerminal)
|
||||||
|
cr.GET("/terminal/:id", cp.terminal)
|
||||||
}
|
}
|
||||||
|
|
||||||
type viewPasswordForm struct {
|
type viewPasswordForm struct {
|
||||||
@ -98,7 +115,10 @@ func (cp *commonPage) home(c *gin.Context) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{}
|
var upgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
}
|
||||||
|
|
||||||
type Data struct {
|
type Data struct {
|
||||||
Now int64 `json:"now,omitempty"`
|
Now int64 `json:"now,omitempty"`
|
||||||
@ -139,3 +159,217 @@ func (cp *commonPage) ws(c *gin.Context) {
|
|||||||
time.Sleep(time.Second * 2)
|
time.Sleep(time.Second * 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cp *commonPage) terminal(c *gin.Context) {
|
||||||
|
log.Println("terminal connected", c.Request.URL)
|
||||||
|
defer log.Println("terminal disconnected", c.Request.URL)
|
||||||
|
terminalID := c.Param("id")
|
||||||
|
cp.terminalsLock.Lock()
|
||||||
|
if terminalID == "" || cp.terminals[terminalID] == nil {
|
||||||
|
cp.terminalsLock.Unlock()
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "无权访问",
|
||||||
|
Msg: "终端会话不存在",
|
||||||
|
Link: "/",
|
||||||
|
Btn: "返回首页",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal := cp.terminals[terminalID]
|
||||||
|
cp.terminalsLock.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
cp.terminalsLock.Lock()
|
||||||
|
defer cp.terminalsLock.Unlock()
|
||||||
|
delete(cp.terminals, terminalID)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var isAgent bool
|
||||||
|
|
||||||
|
if _, authorized := c.Get(model.CtxKeyAuthorizedUser); !authorized {
|
||||||
|
dao.ServerLock.RLock()
|
||||||
|
_, hasID := dao.SecretToID[c.Request.Header.Get("Secret")]
|
||||||
|
dao.ServerLock.RUnlock()
|
||||||
|
if !hasID {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "无权访问",
|
||||||
|
Msg: "用户未登录或非法终端",
|
||||||
|
Link: "/",
|
||||||
|
Btn: "返回首页",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if terminal.userConn == nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "无权访问",
|
||||||
|
Msg: "用户不在线",
|
||||||
|
Link: "/",
|
||||||
|
Btn: "返回首页",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if terminal.agentConn != nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusInternalServerError,
|
||||||
|
Title: "连接已存在",
|
||||||
|
Msg: "Websocket协议切换失败",
|
||||||
|
Link: "/",
|
||||||
|
Btn: "返回首页",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isAgent = true
|
||||||
|
} else {
|
||||||
|
dao.ServerLock.RLock()
|
||||||
|
server := dao.ServerList[terminal.serverID]
|
||||||
|
dao.ServerLock.RUnlock()
|
||||||
|
if server == nil || server.TaskStream == nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "请求失败",
|
||||||
|
Msg: "服务器不存在或处于离线状态",
|
||||||
|
Link: "/server",
|
||||||
|
Btn: "返回重试",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalData, _ := json.Marshal(&model.TerminalTask{
|
||||||
|
Host: terminal.host,
|
||||||
|
UseSSL: terminal.useSSL,
|
||||||
|
Session: terminalID,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := server.TaskStream.Send(&proto.Task{
|
||||||
|
Type: model.TaskTypeTerminal,
|
||||||
|
Data: string(terminalData),
|
||||||
|
}); err != nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "请求失败",
|
||||||
|
Msg: "Agent信令下发失败",
|
||||||
|
Link: "/server",
|
||||||
|
Btn: "返回重试",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusInternalServerError,
|
||||||
|
Title: "网络错误",
|
||||||
|
Msg: "Websocket协议切换失败",
|
||||||
|
Link: "/",
|
||||||
|
Btn: "返回首页",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if isAgent {
|
||||||
|
terminal.agentConn = conn
|
||||||
|
} else {
|
||||||
|
terminal.userConn = conn
|
||||||
|
defer func() {
|
||||||
|
// 用户断开链接时断开 Agent 连接
|
||||||
|
if terminal.agentConn != nil {
|
||||||
|
terminal.agentConn.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
msgType, data, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 将文本消息转换为命令输入
|
||||||
|
if msgType == websocket.TextMessage {
|
||||||
|
data = append([]byte{0}, data...)
|
||||||
|
}
|
||||||
|
// 传递给对方
|
||||||
|
if isAgent {
|
||||||
|
err = terminal.userConn.WriteMessage(websocket.BinaryMessage, data)
|
||||||
|
} else {
|
||||||
|
err = terminal.agentConn.WriteMessage(websocket.BinaryMessage, data)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type createTerminalRequest struct {
|
||||||
|
Host string
|
||||||
|
Protocol string
|
||||||
|
ID uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cp *commonPage) createTerminal(c *gin.Context) {
|
||||||
|
if _, authorized := c.Get(model.CtxKeyAuthorizedUser); !authorized {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "无权访问",
|
||||||
|
Msg: "用户未登录",
|
||||||
|
Link: "/login",
|
||||||
|
Btn: "去登录",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var createTerminalReq createTerminalRequest
|
||||||
|
if err := c.ShouldBind(&createTerminalReq); err != nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "请求失败",
|
||||||
|
Msg: "请求参数有误:" + err.Error(),
|
||||||
|
Link: "/server",
|
||||||
|
Btn: "返回重试",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := uuid.GenerateUUID()
|
||||||
|
if err != nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusInternalServerError,
|
||||||
|
Title: "系统错误",
|
||||||
|
Msg: "生成会话ID失败",
|
||||||
|
Link: "/server",
|
||||||
|
Btn: "返回重试",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dao.ServerLock.RLock()
|
||||||
|
server := dao.ServerList[createTerminalReq.ID]
|
||||||
|
dao.ServerLock.RUnlock()
|
||||||
|
if server == nil || server.TaskStream == nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "请求失败",
|
||||||
|
Msg: "服务器不存在或处于离线状态",
|
||||||
|
Link: "/server",
|
||||||
|
Btn: "返回重试",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cp.terminalsLock.Lock()
|
||||||
|
defer cp.terminalsLock.Unlock()
|
||||||
|
|
||||||
|
cp.terminals[id] = &terminalContext{
|
||||||
|
serverID: createTerminalReq.ID,
|
||||||
|
host: createTerminalReq.Host,
|
||||||
|
useSSL: createTerminalReq.Protocol == "https:",
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "dashboard/terminal", mygin.CommonEnvironment(c, gin.H{
|
||||||
|
"SessionID": id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
@ -5,6 +5,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cloudfoundry.org/bytefmt"
|
"code.cloudfoundry.org/bytefmt"
|
||||||
@ -135,7 +136,7 @@ func ServeWeb(port uint) *http.Server {
|
|||||||
|
|
||||||
func routers(r *gin.Engine) {
|
func routers(r *gin.Engine) {
|
||||||
// 通用页面
|
// 通用页面
|
||||||
cp := commonPage{r}
|
cp := commonPage{r: r, terminals: make(map[string]*terminalContext), terminalsLock: new(sync.Mutex)}
|
||||||
cp.serve()
|
cp.serve()
|
||||||
// 游客页面
|
// 游客页面
|
||||||
gp := guestPage{r}
|
gp := guestPage{r}
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -27,8 +28,41 @@ func main() {
|
|||||||
// httpWithSSLInfo()
|
// httpWithSSLInfo()
|
||||||
// sysinfo()
|
// sysinfo()
|
||||||
// cmdExec()
|
// cmdExec()
|
||||||
resolveIP("ipapi.co", true)
|
// resolveIP("ipapi.co", true)
|
||||||
resolveIP("ipapi.co", false)
|
// resolveIP("ipapi.co", false)
|
||||||
|
log.Println(exec.LookPath("powershell.exe"))
|
||||||
|
defaultShell := os.Getenv("SHELL")
|
||||||
|
if defaultShell == "" {
|
||||||
|
defaultShell = "sh"
|
||||||
|
}
|
||||||
|
cmd := exec.Command(defaultShell)
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
stdoutReader, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
println("Terminal StdoutPipe:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stderrReader, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
println("Terminal StderrPipe: ", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
readers := []io.Reader{stdoutReader, stderrReader}
|
||||||
|
for i := 0; i < len(readers); i++ {
|
||||||
|
go func(j int) {
|
||||||
|
data := make([]byte, 2048)
|
||||||
|
for {
|
||||||
|
count, err := readers[j].Read(data)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
os.Stdout.Write(data[:count])
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveIP(addr string, ipv6 bool) {
|
func resolveIP(addr string, ipv6 bool) {
|
||||||
|
2
go.mod
2
go.mod
@ -13,6 +13,8 @@ require (
|
|||||||
github.com/golang/protobuf v1.4.2
|
github.com/golang/protobuf v1.4.2
|
||||||
github.com/google/go-github v17.0.0+incompatible
|
github.com/google/go-github v17.0.0+incompatible
|
||||||
github.com/gorilla/websocket v1.4.2
|
github.com/gorilla/websocket v1.4.2
|
||||||
|
github.com/hashicorp/go-uuid v1.0.1
|
||||||
|
github.com/kr/pty v1.1.1
|
||||||
github.com/onsi/ginkgo v1.7.0 // indirect
|
github.com/onsi/ginkgo v1.7.0 // indirect
|
||||||
github.com/onsi/gomega v1.4.3 // indirect
|
github.com/onsi/gomega v1.4.3 // indirect
|
||||||
github.com/ory/graceful v0.1.1
|
github.com/ory/graceful v0.1.1
|
||||||
|
2
go.sum
2
go.sum
@ -180,6 +180,7 @@ github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa
|
|||||||
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
|
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
|
||||||
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
||||||
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE=
|
||||||
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
|
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
@ -215,6 +216,7 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
|
|||||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
@ -13,8 +13,18 @@ const (
|
|||||||
TaskTypeICMPPing
|
TaskTypeICMPPing
|
||||||
TaskTypeTCPPing
|
TaskTypeTCPPing
|
||||||
TaskTypeCommand
|
TaskTypeCommand
|
||||||
|
TaskTypeTerminal
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type TerminalTask struct {
|
||||||
|
// websocket 主机名
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
// 是否启用 SSL
|
||||||
|
UseSSL bool `json:"use_ssl,omitempty"`
|
||||||
|
// 会话标识
|
||||||
|
Session string `json:"session,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MonitorCoverAll = iota
|
MonitorCoverAll = iota
|
||||||
MonitorCoverIgnoreAll
|
MonitorCoverIgnoreAll
|
||||||
|
@ -155,6 +155,29 @@ function addOrEditNotification(notification) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function connectToServer(id) {
|
||||||
|
post('/terminal', { Host: window.location.host, Protocol: window.location.protocol, ID: id })
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(path, params, method = 'post') {
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = method;
|
||||||
|
form.action = path;
|
||||||
|
|
||||||
|
for (const key in params) {
|
||||||
|
if (params.hasOwnProperty(key)) {
|
||||||
|
const hiddenField = document.createElement('input');
|
||||||
|
hiddenField.type = 'hidden';
|
||||||
|
hiddenField.name = key;
|
||||||
|
hiddenField.value = params[key];
|
||||||
|
form.appendChild(hiddenField);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
function addOrEditServer(server, conf) {
|
function addOrEditServer(server, conf) {
|
||||||
const modal = $(".server.modal");
|
const modal = $(".server.modal");
|
||||||
modal.children(".header").text((server ? "修改" : "添加") + "服务器");
|
modal.children(".header").text((server ? "修改" : "添加") + "服务器");
|
||||||
|
2
resource/template/common/footer.html
vendored
2
resource/template/common/footer.html
vendored
@ -9,7 +9,7 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/semantic-ui@2.4.1/dist/semantic.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/semantic-ui@2.4.1/dist/semantic.min.js"></script>
|
||||||
<script src="/static/semantic-ui-alerts.min.js"></script>
|
<script src="/static/semantic-ui-alerts.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.min.js"></script>
|
||||||
<script src="/static/main.js?v20210810"></script>
|
<script src="/static/main.js?v20210817"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
@ -49,6 +49,9 @@
|
|||||||
<td style="word-break: break-word;">{{$server.Note}}</td>
|
<td style="word-break: break-word;">{{$server.Note}}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="ui mini icon buttons">
|
<div class="ui mini icon buttons">
|
||||||
|
<button class="ui button" onclick="connectToServer({{$server.ID}})">
|
||||||
|
<i class="terminal icon"></i>
|
||||||
|
</button>
|
||||||
<button class="ui button" onclick="addOrEditServer({{$server.Marshal}})">
|
<button class="ui button" onclick="addOrEditServer({{$server.Marshal}})">
|
||||||
<i class="edit icon"></i>
|
<i class="edit icon"></i>
|
||||||
</button>
|
</button>
|
||||||
|
47
resource/template/dashboard/terminal.html
Normal file
47
resource/template/dashboard/terminal.html
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{{define "dashboard/terminal"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Terminal - {{.Title}}</title>
|
||||||
|
<link rel="shortcut icon" type="image/png" href="/static/logo.svg?v20210804" />
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.13.0/css/xterm.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#terminal-container {
|
||||||
|
padding: unset;
|
||||||
|
margin: unset;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="terminal-container"></div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm@4.13.0/lib/xterm.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-attach@0.6.0/lib/xterm-addon-attach.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const term = new Terminal({
|
||||||
|
screenKeys: true,
|
||||||
|
useStyle: true,
|
||||||
|
cursorBlink: true,
|
||||||
|
});
|
||||||
|
const socket = new WebSocket((window.location.protocol == 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/terminal/' + '{{.SessionID}}');
|
||||||
|
const attachAddon = new AttachAddon.AttachAddon(socket);
|
||||||
|
const fitAddon = new FitAddon.FitAddon();
|
||||||
|
term.loadAddon(attachAddon);
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
term.open(document.getElementById('terminal-container'));
|
||||||
|
fitAddon.fit()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
{{end}}
|
@ -13,7 +13,7 @@ import (
|
|||||||
pb "github.com/naiba/nezha/proto"
|
pb "github.com/naiba/nezha/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "v0.9.20" // !!记得修改 README 中的 badge 版本!!
|
var Version = "v0.9.21" // !!记得修改 README 中的 badge 版本!!
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Conf *model.Config
|
Conf *model.Config
|
||||||
|
Loading…
Reference in New Issue
Block a user