mirror of
https://github.com/nezhahq/nezha.git
synced 2025-01-22 12:48:14 -05:00
✨ v0.9.21 WebSSH
This commit is contained in:
parent
960266bf71
commit
9bf536b68a
@ -4,7 +4,7 @@
|
||||
<br>
|
||||
<small><i>LOGO designed by <a href="https://xio.ng" target="_blank">熊大</a> .</i></small>
|
||||
<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>
|
||||
<p>:trollface: <b>哪吒监控</b> 一站式轻监控轻运维系统。支持系统状态、HTTP(SSL 证书变更、即将到期、到期)、TCP、Ping 监控报警,命令批量执行和计划任务。</p>
|
||||
|
@ -3,20 +3,27 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/genkiroid/cert"
|
||||
"github.com/go-ping/ping"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/kr/pty"
|
||||
"github.com/p14yground/go-github-selfupdate/selfupdate"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
@ -58,6 +65,13 @@ const (
|
||||
networkTimeOut = time.Second * 5 // 普通网络超时
|
||||
)
|
||||
|
||||
type windowSize struct {
|
||||
Rows uint16 `json:"rows"`
|
||||
Cols uint16 `json:"cols"`
|
||||
X uint16
|
||||
Y uint16
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 来自于 GoReleaser 的版本号
|
||||
monitor.Version = version
|
||||
@ -154,7 +168,14 @@ func receiveTasks(tasks pb.NezhaService_RequestTaskClient) error {
|
||||
if err != nil {
|
||||
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.Type = task.GetType()
|
||||
switch task.GetType() {
|
||||
case model.TaskTypeTerminal:
|
||||
handleTerminalTask(task)
|
||||
case model.TaskTypeHTTPGET:
|
||||
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()
|
||||
}
|
||||
handleHttpGetTask(task, &result)
|
||||
case model.TaskTypeICMPPing:
|
||||
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()
|
||||
}
|
||||
handleIcmpPingTask(task, &result)
|
||||
case model.TaskTypeTCPPing:
|
||||
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()
|
||||
}
|
||||
handleTcpPingTask(task, &result)
|
||||
case model.TaskTypeCommand:
|
||||
startedAt := time.Now()
|
||||
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())
|
||||
handleCommandTask(task, &result)
|
||||
default:
|
||||
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{}) {
|
||||
if debug {
|
||||
log.Println(v...)
|
||||
|
@ -1,22 +1,37 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/hashicorp/go-uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/naiba/nezha/model"
|
||||
"github.com/naiba/nezha/pkg/mygin"
|
||||
"github.com/naiba/nezha/proto"
|
||||
"github.com/naiba/nezha/service/dao"
|
||||
)
|
||||
|
||||
type terminalContext struct {
|
||||
agentConn *websocket.Conn
|
||||
userConn *websocket.Conn
|
||||
serverID uint64
|
||||
host string
|
||||
useSSL bool
|
||||
}
|
||||
|
||||
type commonPage struct {
|
||||
r *gin.Engine
|
||||
r *gin.Engine
|
||||
terminals map[string]*terminalContext
|
||||
terminalsLock *sync.Mutex
|
||||
}
|
||||
|
||||
func (cp *commonPage) serve() {
|
||||
@ -27,6 +42,8 @@ func (cp *commonPage) serve() {
|
||||
cr.GET("/", cp.home)
|
||||
cr.GET("/service", cp.service)
|
||||
cr.GET("/ws", cp.ws)
|
||||
cr.POST("/terminal", cp.createTerminal)
|
||||
cr.GET("/terminal/:id", cp.terminal)
|
||||
}
|
||||
|
||||
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 {
|
||||
Now int64 `json:"now,omitempty"`
|
||||
@ -139,3 +159,217 @@ func (cp *commonPage) ws(c *gin.Context) {
|
||||
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"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cloudfoundry.org/bytefmt"
|
||||
@ -135,7 +136,7 @@ func ServeWeb(port uint) *http.Server {
|
||||
|
||||
func routers(r *gin.Engine) {
|
||||
// 通用页面
|
||||
cp := commonPage{r}
|
||||
cp := commonPage{r: r, terminals: make(map[string]*terminalContext), terminalsLock: new(sync.Mutex)}
|
||||
cp.serve()
|
||||
// 游客页面
|
||||
gp := guestPage{r}
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@ -27,8 +28,41 @@ func main() {
|
||||
// httpWithSSLInfo()
|
||||
// sysinfo()
|
||||
// cmdExec()
|
||||
resolveIP("ipapi.co", true)
|
||||
resolveIP("ipapi.co", false)
|
||||
// resolveIP("ipapi.co", true)
|
||||
// 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) {
|
||||
|
2
go.mod
2
go.mod
@ -13,6 +13,8 @@ require (
|
||||
github.com/golang/protobuf v1.4.2
|
||||
github.com/google/go-github v17.0.0+incompatible
|
||||
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/gomega v1.4.3 // indirect
|
||||
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-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.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE=
|
||||
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/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/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
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/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
|
@ -13,8 +13,18 @@ const (
|
||||
TaskTypeICMPPing
|
||||
TaskTypeTCPPing
|
||||
TaskTypeCommand
|
||||
TaskTypeTerminal
|
||||
)
|
||||
|
||||
type TerminalTask struct {
|
||||
// websocket 主机名
|
||||
Host string `json:"host,omitempty"`
|
||||
// 是否启用 SSL
|
||||
UseSSL bool `json:"use_ssl,omitempty"`
|
||||
// 会话标识
|
||||
Session string `json:"session,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
MonitorCoverAll = iota
|
||||
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) {
|
||||
const modal = $(".server.modal");
|
||||
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="/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="/static/main.js?v20210810"></script>
|
||||
<script src="/static/main.js?v20210817"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
@ -49,6 +49,9 @@
|
||||
<td style="word-break: break-word;">{{$server.Note}}</td>
|
||||
<td>
|
||||
<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}})">
|
||||
<i class="edit icon"></i>
|
||||
</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"
|
||||
)
|
||||
|
||||
var Version = "v0.9.20" // !!记得修改 README 中的 badge 版本!!
|
||||
var Version = "v0.9.21" // !!记得修改 README 中的 badge 版本!!
|
||||
|
||||
var (
|
||||
Conf *model.Config
|
||||
|
Loading…
Reference in New Issue
Block a user