feat: add remote screenshot and shell execution capabilities
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Failing after 16s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been skipped
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been skipped

- Add screenshot capture API with quality control
- Add remote shell command execution with timeout
- Implement client-side handlers for screenshot and shell requests
- Add Web UI components for screenshot viewing and shell terminal
- Support auto-refresh for screenshot monitoring
- Add shell command history navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 21:33:09 +08:00
parent a9ca714b24
commit 5cee8daabc
11 changed files with 674 additions and 9 deletions

View File

@@ -1,7 +1,9 @@
package tunnel
import (
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"log"
"net"
@@ -286,6 +288,10 @@ func (c *Client) handleStream(stream net.Conn) {
c.handlePluginAPIRequest(stream, msg)
case protocol.MsgTypeSystemStatsRequest:
c.handleSystemStatsRequest(stream, msg)
case protocol.MsgTypeScreenshotRequest:
c.handleScreenshotRequest(stream, msg)
case protocol.MsgTypeShellExecuteRequest:
c.handleShellExecuteRequest(stream, msg)
}
}
@@ -1280,3 +1286,104 @@ func (c *Client) handleSystemStatsRequest(stream net.Conn, msg *protocol.Message
respMsg, _ := protocol.NewMessage(protocol.MsgTypeSystemStatsResponse, resp)
protocol.WriteMessage(stream, respMsg)
}
// handleScreenshotRequest 处理截图请求
func (c *Client) handleScreenshotRequest(stream net.Conn, msg *protocol.Message) {
defer stream.Close()
var req protocol.ScreenshotRequest
msg.ParsePayload(&req)
// 捕获截图
data, width, height, err := utils.CaptureScreenshot(req.Quality)
if err != nil {
c.logErrorf("Screenshot capture failed: %v", err)
resp := protocol.ScreenshotResponse{Error: err.Error()}
respMsg, _ := protocol.NewMessage(protocol.MsgTypeScreenshotResponse, resp)
protocol.WriteMessage(stream, respMsg)
return
}
// 编码为 Base64
base64Data := base64.StdEncoding.EncodeToString(data)
resp := protocol.ScreenshotResponse{
Data: base64Data,
Width: width,
Height: height,
Timestamp: time.Now().UnixMilli(),
}
respMsg, _ := protocol.NewMessage(protocol.MsgTypeScreenshotResponse, resp)
protocol.WriteMessage(stream, respMsg)
}
// handleShellExecuteRequest 处理 Shell 执行请求
func (c *Client) handleShellExecuteRequest(stream net.Conn, msg *protocol.Message) {
defer stream.Close()
var req protocol.ShellExecuteRequest
if err := msg.ParsePayload(&req); err != nil {
resp := protocol.ShellExecuteResponse{Error: err.Error(), ExitCode: -1}
respMsg, _ := protocol.NewMessage(protocol.MsgTypeShellExecuteResponse, resp)
protocol.WriteMessage(stream, respMsg)
return
}
// 设置默认超时
timeout := req.Timeout
if timeout <= 0 {
timeout = 30
}
c.logf("Executing shell command: %s", req.Command)
// 根据操作系统选择 shell
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", req.Command)
} else {
cmd = exec.Command("sh", "-c", req.Command)
}
// 设置超时上下文
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
// 执行命令并获取输出
output, err := cmd.CombinedOutput()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else if ctx.Err() == context.DeadlineExceeded {
resp := protocol.ShellExecuteResponse{
Output: string(output),
ExitCode: -1,
Error: "command timeout",
}
respMsg, _ := protocol.NewMessage(protocol.MsgTypeShellExecuteResponse, resp)
protocol.WriteMessage(stream, respMsg)
return
} else {
resp := protocol.ShellExecuteResponse{
Output: string(output),
ExitCode: -1,
Error: err.Error(),
}
respMsg, _ := protocol.NewMessage(protocol.MsgTypeShellExecuteResponse, resp)
protocol.WriteMessage(stream, respMsg)
return
}
}
resp := protocol.ShellExecuteResponse{
Output: string(output),
ExitCode: exitCode,
}
respMsg, _ := protocol.NewMessage(protocol.MsgTypeShellExecuteResponse, resp)
protocol.WriteMessage(stream, respMsg)
}

View File

@@ -466,6 +466,47 @@ func (h *ClientHandler) GetSystemStats(c *gin.Context) {
Success(c, stats)
}
// GetScreenshot 获取客户端截图
func (h *ClientHandler) GetScreenshot(c *gin.Context) {
clientID := c.Param("id")
quality := 0
if q, ok := c.GetQuery("quality"); ok {
fmt.Sscanf(q, "%d", &quality)
}
screenshot, err := h.app.GetServer().GetClientScreenshot(clientID, quality)
if err != nil {
InternalError(c, err.Error())
return
}
Success(c, screenshot)
}
// ExecuteShellRequest Shell 执行请求体
type ExecuteShellRequest struct {
Command string `json:"command" binding:"required"`
Timeout int `json:"timeout"`
}
// ExecuteShell 执行 Shell 命令
func (h *ClientHandler) ExecuteShell(c *gin.Context) {
clientID := c.Param("id")
var req ExecuteShellRequest
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, "Invalid request: "+err.Error())
return
}
result, err := h.app.GetServer().ExecuteClientShell(clientID, req.Command, req.Timeout)
if err != nil {
InternalError(c, err.Error())
return
}
Success(c, result)
}
// validateClientID 验证客户端 ID 格式
func validateClientID(id string) bool {
if len(id) < 1 || len(id) > 64 {

View File

@@ -61,7 +61,12 @@ type ServerInterface interface {
// 插件 API 代理
ProxyPluginAPIRequest(clientID string, req protocol.PluginAPIRequest) (*protocol.PluginAPIResponse, error)
// 系统状态
// 系统状态
GetClientSystemStats(clientID string) (*protocol.SystemStatsResponse, error)
// 截图
GetClientScreenshot(clientID string, quality int) (*protocol.ScreenshotResponse, error)
// Shell 执行
ExecuteClientShell(clientID, command string, timeout int) (*protocol.ShellExecuteResponse, error)
}
// ConfigField 配置字段

View File

@@ -70,6 +70,8 @@ func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth,
api.POST("/client/:id/install-plugins", clientHandler.InstallPlugins)
api.POST("/client/:id/plugin/:pluginID/:action", clientHandler.PluginAction)
api.GET("/client/:id/system-stats", clientHandler.GetSystemStats)
api.GET("/client/:id/screenshot", clientHandler.GetScreenshot)
api.POST("/client/:id/shell", clientHandler.ExecuteShell)
// 配置管理
configHandler := handler.NewConfigHandler(app)
@@ -198,10 +200,10 @@ func isStaticAsset(path string) bool {
// Re-export types from handler package for backward compatibility
type (
ServerInterface = handler.ServerInterface
AppInterface = handler.AppInterface
ConfigField = handler.ConfigField
RuleSchema = handler.RuleSchema
PluginInfo = handler.PluginInfo
ServerInterface = handler.ServerInterface
AppInterface = handler.AppInterface
ConfigField = handler.ConfigField
RuleSchema = handler.RuleSchema
PluginInfo = handler.PluginInfo
JSPluginInstallRequest = handler.JSPluginInstallRequest
)

View File

@@ -1975,3 +1975,101 @@ func (s *Server) GetClientSystemStats(clientID string) (*protocol.SystemStatsRes
return &stats, nil
}
// GetClientScreenshot 获取客户端截图
func (s *Server) GetClientScreenshot(clientID string, quality int) (*protocol.ScreenshotResponse, error) {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("client %s not online", clientID)
}
stream, err := cs.Session.Open()
if err != nil {
return nil, err
}
defer stream.Close()
// 设置超时
stream.SetDeadline(time.Now().Add(15 * time.Second))
// 发送请求
req := protocol.ScreenshotRequest{Quality: quality}
msg, _ := protocol.NewMessage(protocol.MsgTypeScreenshotRequest, req)
if err := protocol.WriteMessage(stream, msg); err != nil {
return nil, err
}
// 读取响应
resp, err := protocol.ReadMessage(stream)
if err != nil {
return nil, err
}
if resp.Type != protocol.MsgTypeScreenshotResponse {
return nil, fmt.Errorf("unexpected response type: %d", resp.Type)
}
var screenshot protocol.ScreenshotResponse
if err := resp.ParsePayload(&screenshot); err != nil {
return nil, err
}
if screenshot.Error != "" {
return nil, fmt.Errorf("screenshot failed: %s", screenshot.Error)
}
return &screenshot, nil
}
// ExecuteClientShell 执行客户端 Shell 命令
func (s *Server) ExecuteClientShell(clientID, command string, timeout int) (*protocol.ShellExecuteResponse, error) {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("client %s not online", clientID)
}
stream, err := cs.Session.Open()
if err != nil {
return nil, err
}
defer stream.Close()
// 设置超时 (比命令超时长一点)
if timeout <= 0 {
timeout = 30
}
stream.SetDeadline(time.Now().Add(time.Duration(timeout+5) * time.Second))
// 发送请求
req := protocol.ShellExecuteRequest{
Command: command,
Timeout: timeout,
}
msg, _ := protocol.NewMessage(protocol.MsgTypeShellExecuteRequest, req)
if err := protocol.WriteMessage(stream, msg); err != nil {
return nil, err
}
// 读取响应
resp, err := protocol.ReadMessage(stream)
if err != nil {
return nil, err
}
if resp.Type != protocol.MsgTypeShellExecuteResponse {
return nil, fmt.Errorf("unexpected response type: %d", resp.Type)
}
var result protocol.ShellExecuteResponse
if err := resp.ParsePayload(&result); err != nil {
return nil, err
}
return &result, nil
}