折腾笔记[58]-通过http远程操控powershell
摘要
本文实现了一套基于 HTTP 协议的远程 Shell 执行系统,用于解决离线工控机环境下无法使用 SSH 进行远程管理的痛点。通过 Go 语言编写的单文件可执行程序,在工控机侧启动 HTTP 服务端接收命令,在开发机侧启动客户端实现类似 SSH 的交互式体验。服务端自动检测 PowerShell/CMD 并适配编码,支持命令超时控制、工作目录切换、CORS 跨域等特性。单文件零依赖的部署方式,使其特别适合无法安装额外软件的封闭工控环境。
声明
本文人类为第一作者, 龙虾为通讯作者. 本文有AI生成内容.
简介
telnet简介
Telnet 是一种早期的远程登录协议,基于 TCP 端口 23。它以明文传输所有数据(包括用户名和密码),没有任何加密机制,因此在现代网络环境中已基本被淘汰。Telnet 的主要价值在于其简单性——协议本身仅定义了字符流的双向传输,适合嵌入式设备的调试场景。但由于安全性问题,绝大多数生产环境已禁用 Telnet。
ssh简介
SSH(Secure Shell)是目前最主流的远程管理协议,基于 TCP 端口 22。它通过公钥加密技术实现了安全的身份验证和数据传输,支持端口转发、X11 转发、SFTP 文件传输等高级功能。OpenSSH 是事实上的标准实现,在 Linux/Unix 系统中预装。然而,Windows 系统的 SSH 支持需要额外配置(安装 OpenSSH 服务器或启用可选功能),在封闭的内网工控环境中,往往面临"无法安装软件"、"没有管理员权限"、"系统版本老旧"等限制,导致 SSH 方案难以落地。
为什么选择HTTP方案
在离线工控机场景中,传统的远程管理方式面临多重限制:
- 无法安装 SSH 服务:工控机运行封闭系统,禁止安装第三方软件
- 无管理员权限:无法启用 Windows 自带的 OpenSSH 服务器
- 网络策略限制:防火墙仅开放特定端口,SSH 默认 22 端口常被封锁
- 跨平台需求:需要同时支持 Windows(PowerShell/CMD)和 Linux(Bash)环境
HTTP 方案的优势在于:
- 零依赖部署:单文件可执行程序,无需安装运行时
- 端口灵活:可自定义监听端口,避开防火墙限制
- 协议通用:HTTP 是最基础的网络协议,任何环境都能访问
- 编码自适应:自动处理 Windows GBK/UTF-8 编码问题
- 双角色一体:同一个程序既可当服务端也可当客户端
工程
架构设计
┌─────────────┐ HTTP POST /exec ┌──────────────────┐ 执行命令 ┌─────────────┐
│ 开发机 │ ─────────────────→ │ http_shell_cli │ ─────────→ │ PowerShell │
│ (客户端) │ JSON: {command} │ (工控机运行) │ │ / CMD │
│ │ ←───────────────── │ 0.0.0.0:10022 │ ←───────── │ │
└─────────────┘ JSON: {stdout...} └──────────────────┘ 返回结果 └─────────────┘
核心代码
http_cli_shell.go
package main
import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
// ==================== 配置常量 ====================
const (
DefaultPort = 10022
DefaultHost = "0.0.0.0"
DefaultTimeout = 30
UserAgent = "HTTP-Shell-CLI/1.0"
)
// ==================== 颜色输出 ====================
type Colors struct {
Green string
Red string
Yellow string
Blue string
Cyan string
Gray string
Reset string
Bold string
}
var colors Colors
func initColors() {
if runtime.GOOS == "windows" {
handle := windows.Handle(os.Stdout.Fd())
var mode uint32
if err := windows.GetConsoleMode(handle, &mode); err == nil {
windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING)
}
}
colors = Colors{
Green: "\033[92m",
Red: "\033[91m",
Yellow: "\033[93m",
Blue: "\033[94m",
Cyan: "\033[96m",
Gray: "\033[90m",
Reset: "\033[0m",
Bold: "\033[1m",
}
}
// ==================== Shell 执行引擎 (服务端) ====================
type ShellExecutor struct {
ShellCmd string
}
func NewShellExecutor() *ShellExecutor {
shell := detectShell()
fmt.Printf("[server] 检测到Shell: %s\n", shell)
return &ShellExecutor{ShellCmd: shell}
}
func detectShell() string {
if runtime.GOOS == "windows" {
for _, cmd := range []string{"pwsh.exe", "powershell.exe", "cmd.exe"} {
if path, err := exec.LookPath(cmd); err == nil {
return path
}
}
return "cmd.exe"
}
for _, cmd := range []string{"bash", "sh", "zsh"} {
if path, err := exec.LookPath(cmd); err == nil {
return path
}
}
return "/bin/sh"
}
func (se *ShellExecutor) isPowerShell() bool {
s := strings.ToLower(se.ShellCmd)
return strings.Contains(s, "powershell") || strings.Contains(s, "pwsh")
}
func (se *ShellExecutor) isCmd() bool {
return strings.Contains(strings.ToLower(se.ShellCmd), "cmd.exe")
}
func (se *ShellExecutor) buildCommand(command string) []string {
if se.isPowerShell() {
return []string{se.ShellCmd, "-NoProfile", "-Command", command}
} else if se.isCmd() {
return []string{se.ShellCmd, "/C", command}
}
return []string{se.ShellCmd, "-c", command}
}
func (se *ShellExecutor) getEncoding() string {
if runtime.GOOS == "windows" {
if se.isCmd() {
return "gbk"
}
return "utf-8"
}
return "utf-8"
}
type ExecResult struct {
Status string `json:"status"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
Timeout bool `json:"timeout"`
Command string `json:"command"`
}
func (se *ShellExecutor) Execute(command string, timeoutSec int, workDir string) ExecResult {
if strings.TrimSpace(command) == "" {
return ExecResult{
Status: "error",
Stderr: "空命令",
ExitCode: -1,
Command: command,
}
}
cmdline := se.buildCommand(command)
stdout, stderr, exitCode, timedOut := executeWithTimeout(cmdline, timeoutSec, workDir)
encoding := se.getEncoding()
stdout = decodeOutput([]byte(stdout), encoding)
stderr = decodeOutput([]byte(stderr), encoding)
status := "success"
if exitCode != 0 {
status = "error"
}
return ExecResult{
Status: status,
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
Timeout: timedOut,
Command: command,
}
}
func executeWithTimeout(cmdline []string, timeoutSec int, workDir string) (stdout, stderr string, exitCode int, timedOut bool) {
cmd := exec.Command(cmdline[0], cmdline[1:]...)
if workDir != "" {
if info, err := os.Stat(workDir); err == nil && info.IsDir() {
cmd.Dir = workDir
}
}
if runtime.GOOS == "windows" {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
}
var stdoutBuf, stderrBuf bytes.Buffer
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
done := make(chan error, 1)
if err := cmd.Start(); err != nil {
return "", err.Error(), -1, false
}
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
exitCode = exitError.ExitCode()
} else {
exitCode = -1
stderr = err.Error()
}
}
case <-time.After(time.Duration(timeoutSec) * time.Second):
if cmd.Process != nil {
cmd.Process.Kill()
}
<-done
timedOut = true
exitCode = -1
stderr = fmt.Sprintf("命令执行超时(>%d秒)", timeoutSec)
}
return stdoutBuf.String(), stderrBuf.String(), exitCode, timedOut
}
func decodeOutput(data []byte, encoding string) string {
if encoding == "gbk" && len(data) > 0 {
if isValidUTF8(data) {
return string(data)
}
return gbkToUTF8(data)
}
return string(data)
}
func isValidUTF8(data []byte) bool {
for i := 0; i < len(data); {
if data[i] < 0x80 {
i++
continue
}
if i+1 >= len(data) {
return false
}
if data[i] < 0xE0 {
if data[i] < 0xC2 || data[i+1] < 0x80 || data[i+1] > 0xBF {
return false
}
i += 2
} else if data[i] < 0xF0 {
if i+2 >= len(data) {
return false
}
i += 3
} else {
return false
}
}
return true
}
func gbkToUTF8(data []byte) string {
if runtime.GOOS != "windows" || len(data) == 0 {
return string(data)
}
kernel32 := windows.NewLazySystemDLL("kernel32.dll")
multiByteToWideChar := kernel32.NewProc("MultiByteToWideChar")
wideCharToMultiByte := kernel32.NewProc("WideCharToMultiByte")
const cp936 = 936
const cpUTF8 = 65001
wideLen, _, _ := multiByteToWideChar.Call(uintptr(cp936), 0, uintptr(unsafe.Pointer(&data[0])), uintptr(len(data)), 0, 0)
if wideLen == 0 {
return string(data)
}
wideBuf := make([]uint16, wideLen)
_, _, _ = multiByteToWideChar.Call(uintptr(cp936), 0, uintptr(unsafe.Pointer(&data[0])), uintptr(len(data)),
uintptr(unsafe.Pointer(&wideBuf[0])), wideLen)
utf8Len, _, _ := wideCharToMultiByte.Call(uintptr(cpUTF8), 0, uintptr(unsafe.Pointer(&wideBuf[0])), wideLen, 0, 0, 0, 0)
if utf8Len == 0 {
return string(data)
}
utf8Buf := make([]byte, utf8Len)
_, _, _ = wideCharToMultiByte.Call(uintptr(cpUTF8), 0, uintptr(unsafe.Pointer(&wideBuf[0])), wideLen,
uintptr(unsafe.Pointer(&utf8Buf[0])), utf8Len, 0, 0)
return string(utf8Buf)
}
// ==================== HTTP 服务端 ====================
const usageHTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>HTTP Shell Proxy</title>
<style>
body { font-family: monospace; max-width: 900px; margin: 40px auto; padding: 20px; background: #1e1e1e; color: #d4d4d4; }
h1 { color: #4ec9b0; border-bottom: 2px solid #4ec9b0; padding-bottom: 10px; }
h2 { color: #569cd6; margin-top: 30px; }
code { background: #2d2d2d; padding: 2px 8px; border-radius: 4px; color: #ce9178; }
pre { background: #2d2d2d; padding: 15px; border-radius: 8px; overflow-x: auto; border-left: 4px solid #4ec9b0; }
table { border-collapse: collapse; width: 100%; margin: 15px 0; }
th, td { border: 1px solid #444; padding: 10px; text-align: left; }
th { background: #2d2d2d; color: #4ec9b0; }
.method { color: #4ec9b0; font-weight: bold; }
.url { color: #ce9178; }
.status-ok { color: #4ec9b0; }
.status-err { color: #f44747; }
</style>
</head>
<body>
<h1>🖥️ HTTP Shell Proxy 服务</h1>
<p>通过 HTTP 接口远程执行 Shell 命令(PowerShell/CMD)</p>
<h2>API 接口</h2>
<h3><span class="method">POST</span> <span class="url">/exec</span></h3>
<p>执行 Shell 命令</p>
<p><strong>请求体 (JSON):</strong></p>
<pre>{
"command": "Get-Location",
"timeout": 30,
"work_dir": null
}</pre>
<p><strong>响应 (JSON):</strong></p>
<pre>{
"status": "success",
"stdout": "C:\\Users\\Admin\\n",
"stderr": "",
"exit_code": 0,
"timeout": false,
"command": "Get-Location"
}</pre>
<p><strong>字段说明:</strong></p>
<table>
<tr><th>字段</th><th>类型</th><th>必填</th><th>说明</th></tr>
<tr><td>command</td><td>string</td><td>是</td><td>要执行的 Shell 命令</td></tr>
<tr><td>timeout</td><td>int</td><td>否</td><td>超时秒数,默认 30</td></tr>
<tr><td>work_dir</td><td>string</td><td>否</td><td>执行前切换的工作目录</td></tr>
</table>
<h3><span class="method">GET</span> <span class="url">/health</span></h3>
<p>检查服务状态</p>
<h2>curl 示例</h2>
<pre>curl -X POST http://工控机IP:10022/exec \
-H "Content-Type: application/json" \
-d '{"command": "Get-Location"}'</pre>
<h2>客户端工具</h2>
<p>使用配套的 http_shell_cli.exe 获得类似 SSH 的交互体验:</p>
<pre>http_shell_cli.exe client http://工控机IP:10022</pre>
<hr>
<p style="color:#666;">HTTP Shell Proxy | 离线环境专用 | 无认证模式</p>
</body>
</html>
`
type HealthResponse struct {
Status string `json:"status"`
Shell string `json:"shell"`
Time string `json:"time"`
}
type ExecRequest struct {
Command string `json:"command"`
Timeout int `json:"timeout"`
WorkDir string `json:"work_dir"`
}
func runServer(host string, port int, defaultTimeout int) {
executor := NewShellExecutor()
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/index" {
if r.Method == "GET" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(usageHTML))
return
}
}
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(200)
return
}
http.NotFound(w, r)
})
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Access-Control-Allow-Origin", "*")
resp := HealthResponse{
Status: "running",
Shell: executor.ShellCmd,
Time: time.Now().Format("2006-01-02 15:04:05"),
}
json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("/exec", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(200)
return
}
if r.Method != "POST" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(405)
json.NewEncoder(w).Encode(map[string]string{"error": "Method Not Allowed"})
return
}
var req ExecRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"})
return
}
command := strings.TrimSpace(req.Command)
if command == "" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "Missing 'command' field"})
return
}
timeout := req.Timeout
if timeout <= 0 {
timeout = defaultTimeout
}
clientIP := r.RemoteAddr
userAgent := r.UserAgent()
if userAgent == "" {
userAgent = "-"
}
fmt.Printf("[server] [%s] [%s] 执行命令: %s\n", time.Now().Format("2006-01-02 15:04:05"), clientIP, truncate(command, 80))
startTime := time.Now()
result := executor.Execute(command, timeout, req.WorkDir)
elapsed := time.Since(startTime)
statusIcon := "✓"
if result.Status != "success" {
statusIcon = "✗"
}
fmt.Printf("[server] [%s] [%s] 执行结果: %s | 退出码: %d | 耗时: %v\n",
time.Now().Format("2006-01-02 15:04:05"), clientIP, statusIcon, result.ExitCode, elapsed)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
json.NewEncoder(w).Encode(result)
})
addr := fmt.Sprintf("%s:%d", host, port)
listener, err := net.Listen("tcp", addr)
if err != nil {
fmt.Fprintf(os.Stderr, "[server] 启动失败: %v\n", err)
os.Exit(1)
}
fmt.Println(strings.Repeat("=", 60))
fmt.Println(" HTTP Shell Proxy 服务端已启动")
fmt.Println(strings.Repeat("=", 60))
fmt.Printf(" 监听地址: http://%s\n", addr)
fmt.Printf(" 执行接口: POST http://%s/exec\n", addr)
fmt.Printf(" 状态检查: GET http://%s/health\n", addr)
fmt.Printf(" 使用说明: GET http://%s/\n", addr)
fmt.Printf(" 默认超时: %d秒\n", defaultTimeout)
fmt.Println(strings.Repeat("=", 60))
fmt.Println(" 按 Ctrl+C 停止服务")
fmt.Println(strings.Repeat("=", 60))
server := &http.Server{Handler: mux}
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "[server] 服务异常: %v\n", err)
os.Exit(1)
}
}
func truncate(s string, maxLen int) string {
if len(s) > maxLen {
return s[:maxLen] + "..."
}
return s
}
// ==================== HTTP 客户端 ====================
type ShellClient struct {
BaseURL string
Timeout int
WorkDir string
SessionHistory []string
}
func NewShellClient(baseURL string, timeout int) *ShellClient {
return &ShellClient{
BaseURL: strings.TrimRight(baseURL, "/"),
Timeout: timeout,
}
}
func (sc *ShellClient) request(path string, data interface{}, method string) (map[string]interface{}, error) {
url := sc.BaseURL + path
var body io.Reader
if data != nil {
b, err := json.Marshal(data)
if err != nil {
return nil, err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("User-Agent", UserAgent)
client := &http.Client{Timeout: time.Duration(sc.Timeout+5) * time.Second}
resp, err := client.Do(req)
if err != nil {
return map[string]interface{}{
"status": "error",
"stderr": fmt.Sprintf("连接失败: %v", err),
"exit_code": -1,
}, nil
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
if err := json.Unmarshal(respBody, &result); err != nil {
return map[string]interface{}{
"status": "error",
"stderr": string(respBody),
"exit_code": resp.StatusCode,
}, nil
}
return result, nil
}
func (sc *ShellClient) checkHealth() map[string]interface{} {
result, _ := sc.request("/health", nil, "GET")
return result
}
func (sc *ShellClient) execute(command string, workDir string) map[string]interface{} {
data := map[string]interface{}{
"command": command,
"timeout": sc.Timeout,
}
if workDir != "" {
data["work_dir"] = workDir
}
result, _ := sc.request("/exec", data, "POST")
return result
}
// ==================== 交互式 Shell (客户端) ====================
type InteractiveShell struct {
client *ShellClient
running bool
promptCount int
}
func NewInteractiveShell(client *ShellClient) *InteractiveShell {
return &InteractiveShell{
client: client,
running: true,
}
}
func (is *InteractiveShell) getPrompt() string {
is.promptCount++
dirHint := ""
if is.client.WorkDir != "" {
dirHint = fmt.Sprintf("[%s]", is.client.WorkDir)
}
return fmt.Sprintf("%s[%d]%s%sPS>%s%s ", colors.Cyan, is.promptCount, colors.Reset, colors.Green, colors.Reset, dirHint)
}
func (is *InteractiveShell) printResult(result map[string]interface{}) {
status, _ := result["status"].(string)
stdout, _ := result["stdout"].(string)
stderr, _ := result["stderr"].(string)
exitCode := 0
if v, ok := result["exit_code"].(float64); ok {
exitCode = int(v)
}
timeout, _ := result["timeout"].(bool)
if stdout != "" {
fmt.Println(strings.TrimRight(stdout, "\r\n"))
}
if stderr != "" {
fmt.Printf("%s%s%s\n", colors.Red, strings.TrimRight(stderr, "\r\n"), colors.Reset)
}
if timeout {
fmt.Printf("%s[超时]%s\n", colors.Yellow, colors.Reset)
} else if status != "success" && stderr == "" {
fmt.Printf("%s[退出码: %d]%s\n", colors.Red, exitCode, colors.Reset)
}
}
func (is *InteractiveShell) handleBuiltin(cmdLine string) bool {
parts := strings.Fields(cmdLine)
if len(parts) == 0 {
return true
}
cmd := strings.ToLower(parts[0])
arg := ""
if len(parts) > 1 {
arg = strings.TrimSpace(cmdLine[len(parts[0]):])
}
switch cmd {
case "exit", "quit", "q":
fmt.Printf("%s再见!%s\n", colors.Yellow, colors.Reset)
is.running = false
return true
case "cd":
if arg != "" {
is.client.WorkDir = strings.Trim(arg, `"'`)
fmt.Printf("%s工作目录: %s%s\n", colors.Gray, is.client.WorkDir, colors.Reset)
} else {
is.client.WorkDir = ""
fmt.Printf("%s工作目录已重置%s\n", colors.Gray, colors.Reset)
}
return true
case "pwd":
if is.client.WorkDir != "" {
fmt.Println(is.client.WorkDir)
} else {
fmt.Println("(默认目录)")
}
return true
case "clear", "cls":
if runtime.GOOS == "windows" {
cmd := exec.Command("cmd", "/c", "cls")
cmd.Stdout = os.Stdout
cmd.Run()
} else {
fmt.Print("\033[H\033[2J")
}
return true
case "help", "?":
is.printHelp()
return true
}
if strings.HasPrefix(cmdLine, "!") {
localCmd := strings.TrimSpace(cmdLine[1:])
if localCmd != "" {
cmd := exec.Command("cmd", "/C", localCmd)
if runtime.GOOS != "windows" {
cmd = exec.Command("sh", "-c", localCmd)
}
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("%s本地命令失败: %v%s\n", colors.Red, err, colors.Reset)
}
if len(output) > 0 {
fmt.Println(strings.TrimRight(string(output), "\r\n"))
}
}
return true
}
return false
}
func (is *InteractiveShell) printHelp() {
helpText := fmt.Sprintf(`
%sHTTP Shell Client - 交互式远程Shell%s
%s内置命令:%s
exit, quit, q 退出客户端
cd <目录> 设置后续命令的工作目录
pwd 显示当前工作目录
clear, cls 清屏
!<命令> 执行本地命令(不发送到服务端)
help, ? 显示此帮助
%s使用示例:%s
Get-Location 查看当前目录
Get-ChildItem 列出文件
mkdir hello 创建目录
ipconfig 查看网络配置
Get-Process | Select-Object -First 5
%s提示: 所有命令在远程工控机上执行,结果通过HTTP返回。%s
`, colors.Bold, colors.Reset, colors.Cyan, colors.Reset, colors.Cyan, colors.Reset, colors.Gray, colors.Reset)
fmt.Println(helpText)
}
func (is *InteractiveShell) printBanner() {
banner := fmt.Sprintf(`
%s%s╔══════════════════════════════════════════════════════════════╗
║ HTTP Shell Client - 远程PowerShell控制台 ║
╚══════════════════════════════════════════════════════════════╝%s
服务端: %s%s%s
输入 %shelp%s 查看帮助,%sexit%s 退出
`, colors.Bold, colors.Cyan, colors.Reset, colors.Yellow, is.client.BaseURL, colors.Reset, colors.Green, colors.Reset, colors.Green, colors.Reset)
fmt.Println(banner)
}
func (is *InteractiveShell) run() {
is.printBanner()
health := is.client.checkHealth()
if status, ok := health["status"].(string); ok && status == "running" {
shell, _ := health["shell"].(string)
fmt.Printf("%s✓ 服务端连接成功%s | Shell: %s%s%s\n\n", colors.Green, colors.Reset, colors.Yellow, shell, colors.Reset)
} else {
stderr, _ := health["stderr"].(string)
if stderr == "" {
stderr = "未知错误"
}
fmt.Printf("%s✗ 服务端连接失败: %s%s\n\n", colors.Red, stderr, colors.Reset)
fmt.Printf("%s仍可使用本地命令 (!开头),远程命令将失败。%s\n\n", colors.Yellow, colors.Reset)
}
reader := bufio.NewReader(os.Stdin)
for is.running {
fmt.Print(is.getPrompt())
cmdLine, err := reader.ReadString('\n')
if err != nil {
fmt.Printf("\n%s再见!%s\n", colors.Yellow, colors.Reset)
break
}
cmdLine = strings.TrimSpace(cmdLine)
if cmdLine == "" {
continue
}
if is.handleBuiltin(cmdLine) {
continue
}
result := is.client.execute(cmdLine, is.client.WorkDir)
is.printResult(result)
}
}
func runOnce(client *ShellClient, command string) int {
result := client.execute(command, client.WorkDir)
stdout, _ := result["stdout"].(string)
stderr, _ := result["stderr"].(string)
exitCode := 0
if v, ok := result["exit_code"].(float64); ok {
exitCode = int(v)
}
if stdout != "" {
fmt.Println(strings.TrimRight(stdout, "\r\n"))
}
if stderr != "" {
fmt.Fprintln(os.Stderr, strings.TrimRight(stderr, "\r\n"))
}
return exitCode
}
// ==================== 角色选择 ====================
func selectRole() string {
fmt.Println("=" + strings.Repeat("=", 58))
fmt.Println(" HTTP Shell Proxy - 请选择运行角色")
fmt.Println("=" + strings.Repeat("=", 58))
fmt.Println(" 1) server - 启动服务端(工控机运行)")
fmt.Println(" 2) client - 启动客户端(开发机运行)")
fmt.Println("=" + strings.Repeat("=", 58))
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print("请输入选项 (1/2): ")
input, err := reader.ReadString('\n')
if err != nil {
fmt.Println("读取输入失败")
os.Exit(1)
}
input = strings.TrimSpace(input)
switch input {
case "1", "server", "s":
return "server"
case "2", "client", "c":
return "client"
default:
fmt.Println("无效选项,请重新输入")
}
}
}
// ==================== 主程序 ====================
func main() {
initColors()
// 全局参数
var role string
flag.StringVar(&role, "role", "", "运行角色: server 或 client")
// 服务端参数
var serverHost string
var serverPort int
var serverTimeout int
flag.StringVar(&serverHost, "host", getEnv("HTTP_SHELL_HOST", DefaultHost), "服务端监听地址")
flag.IntVar(&serverPort, "port", getEnvInt("HTTP_SHELL_PORT", DefaultPort), "服务端监听端口")
flag.IntVar(&serverTimeout, "timeout", getEnvInt("HTTP_SHELL_TIMEOUT", DefaultTimeout), "默认命令超时秒数")
// 客户端参数
var clientURL string
var clientCommand string
var clientWorkDir string
flag.StringVar(&clientURL, "url", "", "服务端URL (客户端模式)")
flag.StringVar(&clientCommand, "c", "", "单次执行命令 (客户端模式)")
flag.StringVar(&clientWorkDir, "w", "", "工作目录 (客户端模式)")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "用法: %s [选项]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "模式选择:\n")
fmt.Fprintf(os.Stderr, " -role server 启动服务端\n")
fmt.Fprintf(os.Stderr, " -role client 启动客户端\n")
fmt.Fprintf(os.Stderr, " (不指定-role时进入交互式选择)\n\n")
fmt.Fprintf(os.Stderr, "服务端选项:\n")
fmt.Fprintf(os.Stderr, " -host string 监听地址 (默认: %s)\n", DefaultHost)
fmt.Fprintf(os.Stderr, " -port int 监听端口 (默认: %d)\n", DefaultPort)
fmt.Fprintf(os.Stderr, " -timeout int 默认超时秒数 (默认: %d)\n\n", DefaultTimeout)
fmt.Fprintf(os.Stderr, "客户端选项:\n")
fmt.Fprintf(os.Stderr, " -url string 服务端URL\n")
fmt.Fprintf(os.Stderr, " -c string 单次执行命令\n")
fmt.Fprintf(os.Stderr, " -w string 工作目录\n")
fmt.Fprintf(os.Stderr, "\n示例:\n")
fmt.Fprintf(os.Stderr, " %s -role server -port 10022\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s -role client -url http://192.168.1.100:10022\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s -role client -url http://192.168.1.100:10022 -c \"Get-Location\"\n", os.Args[0])
}
flag.Parse()
// 确定角色
if role == "" {
// 检查是否有位置参数
args := flag.Args()
if len(args) > 0 {
if args[0] == "server" || args[0] == "client" {
role = args[0]
flag.CommandLine.Parse(args[1:])
} else if strings.HasPrefix(args[0], "http://") || strings.HasPrefix(args[0], "https://") {
// 兼容旧版客户端用法: http_shell_cli.exe http://ip:port
role = "client"
clientURL = args[0]
if len(args) > 2 && args[1] == "-c" {
clientCommand = args[2]
}
}
}
}
if role == "" {
role = selectRole()
}
switch role {
case "server", "s":
runServer(serverHost, serverPort, serverTimeout)
case "client", "c":
if clientURL == "" {
clientURL = promptForServerURL()
}
client := NewShellClient(clientURL, serverTimeout)
if clientWorkDir != "" {
client.WorkDir = clientWorkDir
}
if clientCommand != "" {
exitCode := runOnce(client, clientCommand)
os.Exit(exitCode)
} else {
shell := NewInteractiveShell(client)
shell.run()
}
default:
fmt.Fprintf(os.Stderr, "错误: 无效的角色 '%s',请使用 server 或 client\n", role)
os.Exit(1)
}
}
func promptForServerURL() string {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print("请输入服务端地址 (例如 http://192.168.100.55:10022): ")
input, err := reader.ReadString('\n')
if err != nil {
fmt.Fprintf(os.Stderr, "读取输入失败: %v\n", err)
os.Exit(1)
}
input = strings.TrimSpace(input)
if input == "" {
fmt.Println("地址不能为空,请重新输入")
continue
}
if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") {
input = "http://" + input
}
fmt.Printf("正在连接 %s ...\n", input)
testClient := NewShellClient(input, 5)
health := testClient.checkHealth()
if status, ok := health["status"].(string); ok && status == "running" {
shell, _ := health["shell"].(string)
fmt.Printf("%s✓ 连接成功%s | Shell: %s%s%s\n", colors.Green, colors.Reset, colors.Yellow, shell, colors.Reset)
return input
}
stderr, _ := health["stderr"].(string)
if stderr == "" {
stderr = "无法连接到服务端"
}
fmt.Printf("%s✗ 连接失败: %s%s\n", colors.Red, stderr, colors.Reset)
fmt.Print("是否重新输入? (y/n): ")
retry, _ := reader.ReadString('\n')
retry = strings.TrimSpace(strings.ToLower(retry))
if retry == "n" || retry == "no" || retry == "q" || retry == "quit" {
os.Exit(1)
}
}
}
func getEnv(key, defaultValue string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultValue
}
func getEnvInt(key string, defaultValue int) int {
if v := os.Getenv(key); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return defaultValue
}
构建命令
# 初始化模块
go mod init http_shell_cli
# 添加 Windows 依赖
go get golang.org/x/sys/windows
# 构建单文件可执行程序
go build -o http_shell_cli.exe main.go
# 交叉编译(Linux 环境构建 Windows 版本)
GOOS=windows GOARCH=amd64 go build -o http_shell_cli.exe main.go
要求:Go 1.20 或更高版本
使用方式
方式一:交互式选择角色
直接运行程序,按提示选择:
.\http_shell_cli.exe
输出:
============================================================
HTTP Shell Proxy - 请选择运行角色
============================================================
1) server - 启动服务端(工控机运行)
2) client - 启动客户端(开发机运行)
============================================================
请输入选项 (1/2):
方式二:启动参数指定角色
1. 工控机启动服务端
# 使用 -role server 参数
.\http_shell_cli.exe -role server --host 0.0.0.0 --port 10022
# 简写
.\http_shell_cli.exe -role s -port 10022
启动后会显示:
============================================================
HTTP Shell Proxy 服务端已启动
============================================================
监听地址: http://0.0.0.0:10022
执行接口: POST http://0.0.0.0:10022/exec
状态检查: GET http://0.0.0.0:10022/health
使用说明: GET http://0.0.0.0:10022/
默认超时: 30秒
============================================================
按 Ctrl+C 停止服务
============================================================
2. 开发机连接客户端
# 交互式模式(类似SSH)
.\http_shell_cli.exe -role client -url http://工控机IP:10022
# 单次执行模式
.\http_shell_cli.exe -role client -url http://工控机IP:10022 -c "Get-Location"
# 指定超时时间和工作目录
.\http_shell_cli.exe -role client -url http://工控机IP:10022 -timeout 60 -w "C:\Users"
交互过程:
请输入服务端地址 (例如 http://192.168.100.55:10022): 192.168.100.55:10022
正在连接 http://192.168.100.55:10022 ...
✓ 连接成功 | Shell: C:\Windows\System32\cmd.exe
╔══════════════════════════════════════════════════════════════╗
║ HTTP Shell Client - 远程PowerShell控制台 ║
╚══════════════════════════════════════════════════════════════╝
服务端: http://192.168.100.55:10022
输入 help 查看帮助,exit 退出
✓ 服务端连接成功 | Shell: C:\Windows\System32\cmd.exe
[1]PS>
服务端输出
[server] 检测到Shell: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
============================================================
HTTP Shell Proxy 服务端已启动
============================================================
监听地址: http://0.0.0.0:10022
执行接口: POST http://0.0.0.0:10022/exec
状态检查: GET http://0.0.0.0:10022/health
使用说明: GET http://0.0.0.0:10022/
默认超时: 30秒
============================================================
按 Ctrl+C 停止服务
============================================================
[server] [2026-05-08 13:45:46] [192.168.100.10:51401] 执行命令: dir D:\Local
[server] [2026-05-08 13:45:46] [192.168.100.10:51401] 执行结果: ✓ | 退出码: 0 | 耗时: 15.5091ms
客户端输出
╔══════════════════════════════════════════════════════════════╗
║ HTTP Shell Client - 远程PowerShell控制台 ║
╚══════════════════════════════════════════════════════════════╝
服务端: http://192.168.100.55:10022
输入 help 查看帮助,exit 退出
✓ 服务端连接成功 | Shell: C:\Windows\System32\cmd.exe
[1]PS> Get-Location
Path
----
C:\Users\Administrator
[2]PS> Get-Process | Select-Object -First 3
Handles NPM(K) PM(K) WS(K) CPU(s) Id SI ProcessName
------- ------ ----- ----- ------ -- -- -----------
542 29 47232 68212 1.23 1232 0 Application
312 18 23456 34567 0.56 2345 0 Service
128 12 8765 12345 0.12 3456 0 Task
[3]PS> cd D:\Local
工作目录: D:\Local
[4]PS> dir
目录: D:\Local
Mode LastWriteTime Length Name
---- ------------- ------ ----
d----- 2026/5/8 13:30 logs
d----- 2026/5/8 13:25 config
-a---- 2026/5/8 13:28 2048 settings.ini
API 接口
POST /exec
执行 Shell 命令。
请求体:
{
"command": "Get-ChildItem",
"timeout": 30,
"work_dir": "C:\\Users"
}
响应:
{
"status": "success",
"stdout": "...",
"stderr": "",
"exit_code": 0,
"timeout": false,
"command": "Get-ChildItem"
}
GET /health
检查服务状态。
响应:
{
"status": "running",
"shell": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
"time": "2026-05-08 14:30:00"
}
curl 调用示例
# 执行命令
curl -X POST http://工控机IP:10022/exec \
-H "Content-Type: application/json" \
-d '{"command": "Get-Location"}'
# 检查状态
curl http://工控机IP:10022/health
Python 调用示例
import urllib.request
import json
def remote_exec(ip, command, timeout=30):
data = json.dumps({
"command": command,
"timeout": timeout
}).encode('utf-8')
req = urllib.request.Request(
f'http://{ip}:10022/exec',
data=data,
headers={'Content-Type': 'application/json'}
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode('utf-8'))
# 使用
result = remote_exec('192.168.1.100', 'Get-Process')
print(result['stdout'])
环境变量
| 变量名 | 默认值 | 说明 |
|---|---|---|
HTTP_SHELL_PORT |
10022 |
服务端监听端口 |
HTTP_SHELL_HOST |
0.0.0.0 |
服务端监听地址 |
HTTP_SHELL_TIMEOUT |
30 |
默认命令超时秒数 |
客户端内置命令
在交互式模式下,以下命令在本地处理:
| 命令 | 说明 |
|---|---|
exit, quit, q |
退出客户端 |
cd <目录> |
设置后续命令的工作目录 |
pwd |
显示当前工作目录 |
clear, cls |
清屏 |
!<命令> |
执行本地命令(不发送到服务端) |
help, ? |
显示帮助 |
分析
设计权衡
-
无认证 vs 安全性:本服务设计用于离线内网环境,不提供身份认证。这是有意为之的权衡——在封闭工控网络中,物理隔离本身就是安全边界。如需增强安全性,可在前端增加反向代理(如 Nginx)配置 Basic Auth 或 IP 白名单。
-
单文件 vs 模块化:将所有功能编译为单个可执行文件,牺牲代码复用性换取部署便利性。工控环境通常禁止安装运行时(如 Python、.NET),单文件 Go 程序是最佳折中。
-
HTTP vs HTTPS:内网环境使用明文 HTTP,避免证书管理的复杂度。如需加密,建议通过 VPN 或专线传输,而非在应用层处理 TLS。
关键技术点
-
编码自适应:Windows CMD 默认输出 GBK 编码,PowerShell 使用 UTF-8。程序通过
MultiByteToWideChar/WideCharToMultiByteWin32 API 实现 GBK→UTF-8 的实时转换,确保中文输出不乱码。 -
超时控制:使用
select+time.After实现命令级超时,超时后主动Kill进程。这比依赖 shell 内置超时更可靠,因为能处理进程僵死的情况。 -
Shell 自动检测:按
pwsh → powershell → cmd优先级检测 Windows Shell,按bash → sh → zsh优先级检测 Linux Shell,确保在不同环境中都能正常工作。 -
CORS 支持:服务端响应头包含
Access-Control-Allow-Origin: *,允许浏览器端直接调用 API,方便开发 Web 管理界面。
适用场景与局限
| 适用场景 | 不适用场景 |
|---|---|
| 离线内网工控机远程维护 | 公网暴露的生产环境 |
| 无法安装 SSH 的封闭系统 | 需要文件上传/下载的场景 |
| 临时性的命令执行需求 | 高并发批量命令执行 |
| 跨平台(Windows/Linux)统一工具 | 需要交互式 TUI 程序(如 vim) |
扩展建议
- 文件传输:可扩展
/upload和/download接口,通过 multipart/form-data 实现文件传输 - 会话保持:当前每个命令独立执行,可通过 WebSocket 实现真正的交互式会话(支持 vim、top 等 TUI 程序)
- 命令审计:服务端日志已记录时间、IP、命令内容、执行结果,可直接对接日志分析系统
- 权限控制:可扩展基于 Token 的简单认证,或集成 Windows 身份验证
扩展: 支持文件传输版本
http_shell_cli.go
package main
import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"mime"
"mime/multipart"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// ==================== 配置常量 ====================
const (
DefaultPort = 10022
DefaultHost = "0.0.0.0"
DefaultTimeout = 30
UserAgent = "HTTP-Shell-CLI/1.0"
)
// ==================== 颜色输出 ====================
type Colors struct {
Green string
Red string
Yellow string
Blue string
Cyan string
Gray string
Reset string
Bold string
}
var colors Colors
func initColors() {
if runtime.GOOS == "windows" {
enableWindowsVT()
}
colors = Colors{
Green: "\033[92m",
Red: "\033[91m",
Yellow: "\033[93m",
Blue: "\033[94m",
Cyan: "\033[96m",
Gray: "\033[90m",
Reset: "\033[0m",
Bold: "\033[1m",
}
}
// ==================== Shell 执行引擎 (服务端) ====================
type ShellExecutor struct {
ShellCmd string
}
func NewShellExecutor() *ShellExecutor {
shell := detectShell()
fmt.Printf("[server] 检测到Shell: %s\n", shell)
return &ShellExecutor{ShellCmd: shell}
}
func detectShell() string {
if runtime.GOOS == "windows" {
for _, cmd := range []string{"pwsh.exe", "powershell.exe", "cmd.exe"} {
if path, err := exec.LookPath(cmd); err == nil {
return path
}
}
return "cmd.exe"
}
for _, cmd := range []string{"bash", "sh", "zsh"} {
if path, err := exec.LookPath(cmd); err == nil {
return path
}
}
return "/bin/sh"
}
func (se *ShellExecutor) isPowerShell() bool {
s := strings.ToLower(se.ShellCmd)
return strings.Contains(s, "powershell") || strings.Contains(s, "pwsh")
}
func (se *ShellExecutor) isCmd() bool {
return strings.Contains(strings.ToLower(se.ShellCmd), "cmd.exe")
}
func (se *ShellExecutor) buildCommand(command string) []string {
if se.isPowerShell() {
return []string{se.ShellCmd, "-NoProfile", "-Command", command}
} else if se.isCmd() {
return []string{se.ShellCmd, "/C", command}
}
return []string{se.ShellCmd, "-c", command}
}
func (se *ShellExecutor) getEncoding() string {
if runtime.GOOS == "windows" {
if se.isCmd() {
return "gbk"
}
return "utf-8"
}
return "utf-8"
}
type ExecResult struct {
Status string `json:"status"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
Timeout bool `json:"timeout"`
Command string `json:"command"`
}
func (se *ShellExecutor) Execute(command string, timeoutSec int, workDir string) ExecResult {
if strings.TrimSpace(command) == "" {
return ExecResult{
Status: "error",
Stderr: "空命令",
ExitCode: -1,
Command: command,
}
}
cmdline := se.buildCommand(command)
stdout, stderr, exitCode, timedOut := executeWithTimeout(cmdline, timeoutSec, workDir)
encoding := se.getEncoding()
stdout = decodeOutput([]byte(stdout), encoding)
stderr = decodeOutput([]byte(stderr), encoding)
status := "success"
if exitCode != 0 {
status = "error"
}
return ExecResult{
Status: status,
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
Timeout: timedOut,
Command: command,
}
}
func executeWithTimeout(cmdline []string, timeoutSec int, workDir string) (stdout, stderr string, exitCode int, timedOut bool) {
cmd := exec.Command(cmdline[0], cmdline[1:]...)
if workDir != "" {
if info, err := os.Stat(workDir); err == nil && info.IsDir() {
cmd.Dir = workDir
}
}
setHideWindow(cmd)
var stdoutBuf, stderrBuf bytes.Buffer
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
done := make(chan error, 1)
if err := cmd.Start(); err != nil {
return "", err.Error(), -1, false
}
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
exitCode = exitError.ExitCode()
} else {
exitCode = -1
stderr = err.Error()
}
}
case <-time.After(time.Duration(timeoutSec) * time.Second):
if cmd.Process != nil {
cmd.Process.Kill()
}
<-done
timedOut = true
exitCode = -1
stderr = fmt.Sprintf("命令执行超时(>%d秒)", timeoutSec)
}
return stdoutBuf.String(), stderrBuf.String(), exitCode, timedOut
}
func decodeOutput(data []byte, encoding string) string {
if encoding == "gbk" && len(data) > 0 {
if isValidUTF8(data) {
return string(data)
}
return gbkToUTF8(data)
}
return string(data)
}
func isValidUTF8(data []byte) bool {
for i := 0; i < len(data); {
if data[i] < 0x80 {
i++
continue
}
if i+1 >= len(data) {
return false
}
if data[i] < 0xE0 {
if data[i] < 0xC2 || data[i+1] < 0x80 || data[i+1] > 0xBF {
return false
}
i += 2
} else if data[i] < 0xF0 {
if i+2 >= len(data) {
return false
}
i += 3
} else {
return false
}
}
return true
}
func gbkToUTF8(data []byte) string {
if runtime.GOOS != "windows" || len(data) == 0 {
return string(data)
}
return windowsGbkToUTF8(data)
}
// ==================== HTTP 服务端 ====================
const usageHTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>HTTP Shell Proxy</title>
<style>
body { font-family: monospace; max-width: 900px; margin: 40px auto; padding: 20px; background: #1e1e1e; color: #d4d4d4; }
h1 { color: #4ec9b0; border-bottom: 2px solid #4ec9b0; padding-bottom: 10px; }
h2 { color: #569cd6; margin-top: 30px; }
code { background: #2d2d2d; padding: 2px 8px; border-radius: 4px; color: #ce9178; }
pre { background: #2d2d2d; padding: 15px; border-radius: 8px; overflow-x: auto; border-left: 4px solid #4ec9b0; }
table { border-collapse: collapse; width: 100%; margin: 15px 0; }
th, td { border: 1px solid #444; padding: 10px; text-align: left; }
th { background: #2d2d2d; color: #4ec9b0; }
.method { color: #4ec9b0; font-weight: bold; }
.url { color: #ce9178; }
.status-ok { color: #4ec9b0; }
.status-err { color: #f44747; }
</style>
</head>
<body>
<h1>🖥️ HTTP Shell Proxy 服务</h1>
<p>通过 HTTP 接口远程执行 Shell 命令(PowerShell/CMD),支持文件上传下载</p>
<h2>API 接口</h2>
<h3><span class="method">POST</span> <span class="url">/exec</span></h3>
<p>执行 Shell 命令</p>
<p><strong>请求体 (JSON):</strong></p>
<pre>{
"command": "Get-Location",
"timeout": 30,
"work_dir": null
}</pre>
<p><strong>响应 (JSON):</strong></p>
<pre>{
"status": "success",
"stdout": "C:\\Users\\Admin\\n",
"stderr": "",
"exit_code": 0,
"timeout": false,
"command": "Get-Location"
}</pre>
<p><strong>字段说明:</strong></p>
<table>
<tr><th>字段</th><th>类型</th><th>必填</th><th>说明</th></tr>
<tr><td>command</td><td>string</td><td>是</td><td>要执行的 Shell 命令</td></tr>
<tr><td>timeout</td><td>int</td><td>否</td><td>超时秒数,默认 30</td></tr>
<tr><td>work_dir</td><td>string</td><td>否</td><td>执行前切换的工作目录</td></tr>
</table>
<h3><span class="method">POST</span> <span class="url">/upload</span></h3>
<p>上传文件到服务端(multipart/form-data)</p>
<p><strong>请求参数:</strong></p>
<table>
<tr><th>字段</th><th>类型</th><th>必填</th><th>说明</th></tr>
<tr><td>file</td><td>file</td><td>是</td><td>要上传的文件</td></tr>
<tr><td>path</td><td>string</td><td>否</td><td>服务端保存路径(默认保存到当前目录,保留原文件名)</td></tr>
</table>
<p><strong>curl 示例:</strong></p>
<pre>curl -X POST http://工控机IP:10022/upload \
-F "file=@local.txt" \
-F "path=C:/remote/dir/"</pre>
<p><strong>响应 (JSON):</strong></p>
<pre>{
"status": "success",
"message": "文件上传成功",
"saved_as": "C:/remote/dir/local.txt",
"size": 1234
}</pre>
<h3><span class="method">POST</span> <span class="url">/download</span></h3>
<p>从服务端下载文件(multipart/form-data 请求文件名)</p>
<p><strong>请求参数:</strong></p>
<table>
<tr><th>字段</th><th>类型</th><th>必填</th><th>说明</th></tr>
<tr><td>path</td><td>string</td><td>是</td><td>服务端文件路径</td></tr>
</table>
<p><strong>curl 示例:</strong></p>
<pre>curl -X POST http://工控机IP:10022/download \
-F "path=C:/remote/dir/file.txt" \
-o local.txt</pre>
<p><strong>响应:</strong> 文件二进制内容,Content-Disposition: attachment</p>
<h3><span class="method">GET</span> <span class="url">/health</span></h3>
<p>检查服务状态</p>
<h2>curl 示例</h2>
<pre>curl -X POST http://工控机IP:10022/exec \
-H "Content-Type: application/json" \
-d '{"command": "Get-Location"}'</pre>
<h2>客户端工具</h2>
<p>使用配套的 http_shell_cli.exe 获得类似 SSH 的交互体验:</p>
<pre>http_shell_cli.exe client http://工控机IP:10022</pre>
<p>交互模式下支持内置命令: upload <本地路径> [远程路径], download <远程路径> [本地路径]</p>
<hr>
<p style="color:#666;">HTTP Shell Proxy | 离线环境专用 | 无认证模式</p>
</body>
</html>
`
type HealthResponse struct {
Status string `json:"status"`
Shell string `json:"shell"`
Time string `json:"time"`
}
type ExecRequest struct {
Command string `json:"command"`
Timeout int `json:"timeout"`
WorkDir string `json:"work_dir"`
}
func runServer(host string, port int, defaultTimeout int) {
executor := NewShellExecutor()
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/index" {
if r.Method == "GET" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(usageHTML))
return
}
}
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(200)
return
}
http.NotFound(w, r)
})
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Access-Control-Allow-Origin", "*")
resp := HealthResponse{
Status: "running",
Shell: executor.ShellCmd,
Time: time.Now().Format("2006-01-02 15:04:05"),
}
json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("/exec", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(200)
return
}
if r.Method != "POST" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(405)
json.NewEncoder(w).Encode(map[string]string{"error": "Method Not Allowed"})
return
}
var req ExecRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"})
return
}
command := strings.TrimSpace(req.Command)
if command == "" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "Missing 'command' field"})
return
}
timeout := req.Timeout
if timeout <= 0 {
timeout = defaultTimeout
}
clientIP := r.RemoteAddr
userAgent := r.UserAgent()
if userAgent == "" {
userAgent = "-"
}
fmt.Printf("[server] [%s] [%s] 执行命令: %s\n", time.Now().Format("2006-01-02 15:04:05"), clientIP, truncate(command, 80))
startTime := time.Now()
result := executor.Execute(command, timeout, req.WorkDir)
elapsed := time.Since(startTime)
statusIcon := "✓"
if result.Status != "success" {
statusIcon = "✗"
}
fmt.Printf("[server] [%s] [%s] 执行结果: %s | 退出码: %d | 耗时: %v\n",
time.Now().Format("2006-01-02 15:04:05"), clientIP, statusIcon, result.ExitCode, elapsed)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
json.NewEncoder(w).Encode(result)
})
// ===== /upload =====
mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(200)
return
}
if r.Method != "POST" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(405)
json.NewEncoder(w).Encode(map[string]string{"error": "Method Not Allowed"})
return
}
// 解析 multipart/form-data,限制 100MB
if err := r.ParseMultipartForm(100 << 20); err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid multipart form: " + err.Error()})
return
}
defer r.MultipartForm.RemoveAll()
file, header, err := r.FormFile("file")
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "Missing 'file' field: " + err.Error()})
return
}
defer file.Close()
saveDir := r.FormValue("path")
if saveDir == "" {
saveDir = "."
}
// 判断用户意图:以 / 或 \ 结尾表示目录
isDirIntent := strings.HasSuffix(saveDir, "/") || strings.HasSuffix(saveDir, "\\")
cleanPath := strings.TrimRight(saveDir, "/\\")
var savePath string
info, err := os.Stat(cleanPath)
if err == nil && info.IsDir() {
// path 是已存在的目录
savePath = filepath.Join(cleanPath, header.Filename)
} else if isDirIntent {
// 用户意图是目录,但目录不存在,创建它
if err := os.MkdirAll(cleanPath, 0755); err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(500)
json.NewEncoder(w).Encode(map[string]string{"error": "Cannot create directory: " + err.Error()})
return
}
savePath = filepath.Join(cleanPath, header.Filename)
} else {
// path 不存在或是文件路径
parent := filepath.Dir(cleanPath)
if parent != "." && parent != "/" {
if err := os.MkdirAll(parent, 0755); err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(500)
json.NewEncoder(w).Encode(map[string]string{"error": "Cannot create directory: " + err.Error()})
return
}
}
savePath = cleanPath
}
out, err := os.Create(savePath)
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(500)
json.NewEncoder(w).Encode(map[string]string{"error": "Cannot create file: " + err.Error()})
return
}
defer out.Close()
size, err := io.Copy(out, file)
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(500)
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to save file: " + err.Error()})
return
}
clientIP := r.RemoteAddr
fmt.Printf("[server] [%s] [%s] 上传文件: %s -> %s (%d bytes)\n",
time.Now().Format("2006-01-02 15:04:05"), clientIP, header.Filename, savePath, size)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "success",
"message": "文件上传成功",
"saved_as": savePath,
"size": size,
})
})
// ===== /download =====
mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(200)
return
}
if r.Method != "POST" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(405)
json.NewEncoder(w).Encode(map[string]string{"error": "Method Not Allowed"})
return
}
// 支持 multipart/form-data 和 application/x-www-form-urlencoded
var filePath string
contentType := r.Header.Get("Content-Type")
if strings.Contains(contentType, "multipart/form-data") {
if err := r.ParseMultipartForm(10 << 20); err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid multipart form: " + err.Error()})
return
}
defer r.MultipartForm.RemoveAll()
filePath = r.FormValue("path")
} else {
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid form: " + err.Error()})
return
}
filePath = r.PostFormValue("path")
}
filePath = strings.TrimSpace(filePath)
if filePath == "" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "Missing 'path' field"})
return
}
info, err := os.Stat(filePath)
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(404)
json.NewEncoder(w).Encode(map[string]string{"error": "File not found: " + err.Error()})
return
}
if info.IsDir() {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "Path is a directory"})
return
}
f, err := os.Open(filePath)
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(500)
json.NewEncoder(w).Encode(map[string]string{"error": "Cannot open file: " + err.Error()})
return
}
defer f.Close()
filename := filepath.Base(filePath)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
clientIP := r.RemoteAddr
fmt.Printf("[server] [%s] [%s] 下载文件: %s (%d bytes)\n",
time.Now().Format("2006-01-02 15:04:05"), clientIP, filePath, info.Size())
io.Copy(w, f)
})
addr := fmt.Sprintf("%s:%d", host, port)
listener, err := net.Listen("tcp", addr)
if err != nil {
fmt.Fprintf(os.Stderr, "[server] 启动失败: %v\n", err)
os.Exit(1)
}
fmt.Println(strings.Repeat("=", 60))
fmt.Println(" HTTP Shell Proxy 服务端已启动")
fmt.Println(strings.Repeat("=", 60))
fmt.Printf(" 监听地址: http://%s\n", addr)
fmt.Printf(" 执行接口: POST http://%s/exec\n", addr)
fmt.Printf(" 文件上传: POST http://%s/upload\n", addr)
fmt.Printf(" 文件下载: POST http://%s/download\n", addr)
fmt.Printf(" 状态检查: GET http://%s/health\n", addr)
fmt.Printf(" 使用说明: GET http://%s/\n", addr)
fmt.Printf(" 默认超时: %d秒\n", defaultTimeout)
fmt.Println(strings.Repeat("=", 60))
fmt.Println(" 按 Ctrl+C 停止服务")
fmt.Println(strings.Repeat("=", 60))
server := &http.Server{Handler: mux}
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "[server] 服务异常: %v\n", err)
os.Exit(1)
}
}
func truncate(s string, maxLen int) string {
if len(s) > maxLen {
return s[:maxLen] + "..."
}
return s
}
// ==================== HTTP 客户端 ====================
type ShellClient struct {
BaseURL string
Timeout int
WorkDir string
SessionHistory []string
}
func NewShellClient(baseURL string, timeout int) *ShellClient {
return &ShellClient{
BaseURL: strings.TrimRight(baseURL, "/"),
Timeout: timeout,
}
}
func (sc *ShellClient) request(path string, data interface{}, method string) (map[string]interface{}, error) {
url := sc.BaseURL + path
var body io.Reader
if data != nil {
b, err := json.Marshal(data)
if err != nil {
return nil, err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("User-Agent", UserAgent)
client := &http.Client{Timeout: time.Duration(sc.Timeout+5) * time.Second}
resp, err := client.Do(req)
if err != nil {
return map[string]interface{}{
"status": "error",
"stderr": fmt.Sprintf("连接失败: %v", err),
"exit_code": -1,
}, nil
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
if err := json.Unmarshal(respBody, &result); err != nil {
return map[string]interface{}{
"status": "error",
"stderr": string(respBody),
"exit_code": resp.StatusCode,
}, nil
}
return result, nil
}
func (sc *ShellClient) checkHealth() map[string]interface{} {
result, _ := sc.request("/health", nil, "GET")
return result
}
func (sc *ShellClient) execute(command string, workDir string) map[string]interface{} {
data := map[string]interface{}{
"command": command,
"timeout": sc.Timeout,
}
if workDir != "" {
data["work_dir"] = workDir
}
result, _ := sc.request("/exec", data, "POST")
return result
}
func (sc *ShellClient) upload(localPath, remotePath string) map[string]interface{} {
file, err := os.Open(localPath)
if err != nil {
return map[string]interface{}{
"status": "error",
"stderr": fmt.Sprintf("打开本地文件失败: %v", err),
"exit_code": -1,
}
}
defer file.Close()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
// 添加文件
filename := filepath.Base(localPath)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
return map[string]interface{}{
"status": "error",
"stderr": fmt.Sprintf("创建表单失败: %v", err),
"exit_code": -1,
}
}
if _, err := io.Copy(part, file); err != nil {
return map[string]interface{}{
"status": "error",
"stderr": fmt.Sprintf("读取文件失败: %v", err),
"exit_code": -1,
}
}
// 添加远程路径
if remotePath != "" {
writer.WriteField("path", remotePath)
}
writer.Close()
url := sc.BaseURL + "/upload"
req, err := http.NewRequest("POST", url, &body)
if err != nil {
return map[string]interface{}{
"status": "error",
"stderr": fmt.Sprintf("创建请求失败: %v", err),
"exit_code": -1,
}
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("User-Agent", UserAgent)
client := &http.Client{Timeout: time.Duration(sc.Timeout+30) * time.Second}
resp, err := client.Do(req)
if err != nil {
return map[string]interface{}{
"status": "error",
"stderr": fmt.Sprintf("上传失败: %v", err),
"exit_code": -1,
}
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
if err := json.Unmarshal(respBody, &result); err != nil {
return map[string]interface{}{
"status": "error",
"stderr": string(respBody),
"exit_code": resp.StatusCode,
}
}
return result
}
func (sc *ShellClient) download(remotePath, localPath string) map[string]interface{} {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
writer.WriteField("path", remotePath)
writer.Close()
url := sc.BaseURL + "/download"
req, err := http.NewRequest("POST", url, &body)
if err != nil {
return map[string]interface{}{
"status": "error",
"stderr": fmt.Sprintf("创建请求失败: %v", err),
"exit_code": -1,
}
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("User-Agent", UserAgent)
client := &http.Client{Timeout: time.Duration(sc.Timeout+30) * time.Second}
resp, err := client.Do(req)
if err != nil {
return map[string]interface{}{
"status": "error",
"stderr": fmt.Sprintf("下载失败: %v", err),
"exit_code": -1,
}
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
if err := json.Unmarshal(respBody, &result); err != nil {
return map[string]interface{}{
"status": "error",
"stderr": string(respBody),
"exit_code": resp.StatusCode,
}
}
return result
}
// 确定本地保存路径
if localPath == "" {
_, params, _ := mime.ParseMediaType(resp.Header.Get("Content-Disposition"))
if params["filename"] != "" {
localPath = params["filename"]
} else {
localPath = filepath.Base(remotePath)
}
}
out, err := os.Create(localPath)
if err != nil {
return map[string]interface{}{
"status": "error",
"stderr": fmt.Sprintf("创建本地文件失败: %v", err),
"exit_code": -1,
}
}
defer out.Close()
size, err := io.Copy(out, resp.Body)
if err != nil {
return map[string]interface{}{
"status": "error",
"stderr": fmt.Sprintf("保存文件失败: %v", err),
"exit_code": -1,
}
}
return map[string]interface{}{
"status": "success",
"message": "文件下载成功",
"saved_as": localPath,
"size": size,
}
}
// ==================== 交互式 Shell (客户端) ====================
type InteractiveShell struct {
client *ShellClient
running bool
promptCount int
}
func NewInteractiveShell(client *ShellClient) *InteractiveShell {
return &InteractiveShell{
client: client,
running: true,
}
}
func (is *InteractiveShell) getPrompt() string {
is.promptCount++
dirHint := ""
if is.client.WorkDir != "" {
dirHint = fmt.Sprintf("[%s]", is.client.WorkDir)
}
return fmt.Sprintf("%s[%d]%s%sPS>%s%s ", colors.Cyan, is.promptCount, colors.Reset, colors.Green, colors.Reset, dirHint)
}
func (is *InteractiveShell) printResult(result map[string]interface{}) {
status, _ := result["status"].(string)
stdout, _ := result["stdout"].(string)
stderr, _ := result["stderr"].(string)
exitCode := 0
if v, ok := result["exit_code"].(float64); ok {
exitCode = int(v)
}
timeout, _ := result["timeout"].(bool)
if stdout != "" {
fmt.Println(strings.TrimRight(stdout, "\r\n"))
}
if stderr != "" {
fmt.Printf("%s%s%s\n", colors.Red, strings.TrimRight(stderr, "\r\n"), colors.Reset)
}
if timeout {
fmt.Printf("%s[超时]%s\n", colors.Yellow, colors.Reset)
} else if status != "success" && stderr == "" {
fmt.Printf("%s[退出码: %d]%s\n", colors.Red, exitCode, colors.Reset)
}
}
func (is *InteractiveShell) printFileResult(result map[string]interface{}) {
status, _ := result["status"].(string)
message, _ := result["message"].(string)
savedAs, _ := result["saved_as"].(string)
stderr, _ := result["stderr"].(string)
size := int64(0)
if v, ok := result["size"].(float64); ok {
size = int64(v)
}
if status == "success" {
fmt.Printf("%s✓ %s%s\n", colors.Green, message, colors.Reset)
if savedAs != "" {
fmt.Printf(" 保存路径: %s\n", savedAs)
}
if size > 0 {
fmt.Printf(" 文件大小: %d bytes\n", size)
}
} else {
if stderr != "" {
fmt.Printf("%s✗ %s%s\n", colors.Red, stderr, colors.Reset)
} else {
fmt.Printf("%s✗ 操作失败%s\n", colors.Red, colors.Reset)
}
}
}
func (is *InteractiveShell) handleBuiltin(cmdLine string) bool {
parts := strings.Fields(cmdLine)
if len(parts) == 0 {
return true
}
cmd := strings.ToLower(parts[0])
arg := ""
if len(parts) > 1 {
arg = strings.TrimSpace(cmdLine[len(parts[0]):])
}
switch cmd {
case "exit", "quit", "q":
fmt.Printf("%s再见!%s\n", colors.Yellow, colors.Reset)
is.running = false
return true
case "cd":
if arg != "" {
is.client.WorkDir = strings.Trim(arg, `"'`)
fmt.Printf("%s工作目录: %s%s\n", colors.Gray, is.client.WorkDir, colors.Reset)
} else {
is.client.WorkDir = ""
fmt.Printf("%s工作目录已重置%s\n", colors.Gray, colors.Reset)
}
return true
case "pwd":
if is.client.WorkDir != "" {
fmt.Println(is.client.WorkDir)
} else {
fmt.Println("(默认目录)")
}
return true
case "clear", "cls":
if runtime.GOOS == "windows" {
cmd := exec.Command("cmd", "/c", "cls")
cmd.Stdout = os.Stdout
cmd.Run()
} else {
fmt.Print("\033[H\033[2J")
}
return true
case "help", "?":
is.printHelp()
return true
case "upload":
if len(parts) < 2 {
fmt.Printf("%s用法: upload <本地路径> [远程路径]%s\n", colors.Yellow, colors.Reset)
return true
}
localPath := parts[1]
remotePath := ""
if len(parts) > 2 {
remotePath = parts[2]
}
fmt.Printf("%s正在上传 %s ...%s\n", colors.Gray, localPath, colors.Reset)
result := is.client.upload(localPath, remotePath)
is.printFileResult(result)
return true
case "download":
if len(parts) < 2 {
fmt.Printf("%s用法: download <远程路径> [本地路径]%s\n", colors.Yellow, colors.Reset)
return true
}
remotePath := parts[1]
localPath := ""
if len(parts) > 2 {
localPath = parts[2]
}
fmt.Printf("%s正在下载 %s ...%s\n", colors.Gray, remotePath, colors.Reset)
result := is.client.download(remotePath, localPath)
is.printFileResult(result)
return true
}
if strings.HasPrefix(cmdLine, "!") {
localCmd := strings.TrimSpace(cmdLine[1:])
if localCmd != "" {
cmd := exec.Command("cmd", "/C", localCmd)
if runtime.GOOS != "windows" {
cmd = exec.Command("sh", "-c", localCmd)
}
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("%s本地命令失败: %v%s\n", colors.Red, err, colors.Reset)
}
if len(output) > 0 {
fmt.Println(strings.TrimRight(string(output), "\r\n"))
}
}
return true
}
return false
}
func (is *InteractiveShell) printHelp() {
helpText := fmt.Sprintf(`
%sHTTP Shell Client - 交互式远程Shell%s
%s内置命令:%s
exit, quit, q 退出客户端
cd <目录> 设置后续命令的工作目录
pwd 显示当前工作目录
clear, cls 清屏
!<命令> 执行本地命令(不发送到服务端)
help, ? 显示此帮助
%s使用示例:%s
Get-Location 查看当前目录
Get-ChildItem 列出文件
mkdir hello 创建目录
ipconfig 查看网络配置
Get-Process | Select-Object -First 5
%s提示: 所有命令在远程工控机上执行,结果通过HTTP返回。%s
`, colors.Bold, colors.Reset, colors.Cyan, colors.Reset, colors.Cyan, colors.Reset, colors.Gray, colors.Reset)
fmt.Println(helpText)
}
func (is *InteractiveShell) printBanner() {
banner := fmt.Sprintf(`
%s%s╔══════════════════════════════════════════════════════════════╗
║ HTTP Shell Client - 远程PowerShell控制台 ║
╚══════════════════════════════════════════════════════════════╝%s
服务端: %s%s%s
输入 %shelp%s 查看帮助,%sexit%s 退出
`, colors.Bold, colors.Cyan, colors.Reset, colors.Yellow, is.client.BaseURL, colors.Reset, colors.Green, colors.Reset, colors.Green, colors.Reset)
fmt.Println(banner)
}
func (is *InteractiveShell) run() {
is.printBanner()
health := is.client.checkHealth()
if status, ok := health["status"].(string); ok && status == "running" {
shell, _ := health["shell"].(string)
fmt.Printf("%s✓ 服务端连接成功%s | Shell: %s%s%s\n\n", colors.Green, colors.Reset, colors.Yellow, shell, colors.Reset)
} else {
stderr, _ := health["stderr"].(string)
if stderr == "" {
stderr = "未知错误"
}
fmt.Printf("%s✗ 服务端连接失败: %s%s\n\n", colors.Red, stderr, colors.Reset)
fmt.Printf("%s仍可使用本地命令 (!开头),远程命令将失败。%s\n\n", colors.Yellow, colors.Reset)
}
reader := bufio.NewReader(os.Stdin)
for is.running {
fmt.Print(is.getPrompt())
cmdLine, err := reader.ReadString('\n')
if err != nil {
fmt.Printf("\n%s再见!%s\n", colors.Yellow, colors.Reset)
break
}
cmdLine = strings.TrimSpace(cmdLine)
if cmdLine == "" {
continue
}
if is.handleBuiltin(cmdLine) {
continue
}
result := is.client.execute(cmdLine, is.client.WorkDir)
is.printResult(result)
}
}
func runOnce(client *ShellClient, command string) int {
result := client.execute(command, client.WorkDir)
stdout, _ := result["stdout"].(string)
stderr, _ := result["stderr"].(string)
exitCode := 0
if v, ok := result["exit_code"].(float64); ok {
exitCode = int(v)
}
if stdout != "" {
fmt.Println(strings.TrimRight(stdout, "\r\n"))
}
if stderr != "" {
fmt.Fprintln(os.Stderr, strings.TrimRight(stderr, "\r\n"))
}
return exitCode
}
// ==================== 角色选择 ====================
func selectRole() string {
fmt.Println("=" + strings.Repeat("=", 58))
fmt.Println(" HTTP Shell Proxy - 请选择运行角色")
fmt.Println("=" + strings.Repeat("=", 58))
fmt.Println(" 1) server - 启动服务端(工控机运行)")
fmt.Println(" 2) client - 启动客户端(开发机运行)")
fmt.Println("=" + strings.Repeat("=", 58))
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print("请输入选项 (1/2): ")
input, err := reader.ReadString('\n')
if err != nil {
fmt.Println("读取输入失败")
os.Exit(1)
}
input = strings.TrimSpace(input)
switch input {
case "1", "server", "s":
return "server"
case "2", "client", "c":
return "client"
default:
fmt.Println("无效选项,请重新输入")
}
}
}
// ==================== 主程序 ====================
func main() {
initColors()
// 全局参数
var role string
flag.StringVar(&role, "role", "", "运行角色: server 或 client")
// 服务端参数
var serverHost string
var serverPort int
var serverTimeout int
flag.StringVar(&serverHost, "host", getEnv("HTTP_SHELL_HOST", DefaultHost), "服务端监听地址")
flag.IntVar(&serverPort, "port", getEnvInt("HTTP_SHELL_PORT", DefaultPort), "服务端监听端口")
flag.IntVar(&serverTimeout, "timeout", getEnvInt("HTTP_SHELL_TIMEOUT", DefaultTimeout), "默认命令超时秒数")
// 客户端参数
var clientURL string
var clientCommand string
var clientWorkDir string
flag.StringVar(&clientURL, "url", "", "服务端URL (客户端模式)")
flag.StringVar(&clientCommand, "c", "", "单次执行命令 (客户端模式)")
flag.StringVar(&clientWorkDir, "w", "", "工作目录 (客户端模式)")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "用法: %s [选项]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "模式选择:\n")
fmt.Fprintf(os.Stderr, " -role server 启动服务端\n")
fmt.Fprintf(os.Stderr, " -role client 启动客户端\n")
fmt.Fprintf(os.Stderr, " (不指定-role时进入交互式选择)\n\n")
fmt.Fprintf(os.Stderr, "服务端选项:\n")
fmt.Fprintf(os.Stderr, " -host string 监听地址 (默认: %s)\n", DefaultHost)
fmt.Fprintf(os.Stderr, " -port int 监听端口 (默认: %d)\n", DefaultPort)
fmt.Fprintf(os.Stderr, " -timeout int 默认超时秒数 (默认: %d)\n\n", DefaultTimeout)
fmt.Fprintf(os.Stderr, "客户端选项:\n")
fmt.Fprintf(os.Stderr, " -url string 服务端URL\n")
fmt.Fprintf(os.Stderr, " -c string 单次执行命令\n")
fmt.Fprintf(os.Stderr, " -w string 工作目录\n")
fmt.Fprintf(os.Stderr, "\n示例:\n")
fmt.Fprintf(os.Stderr, " %s -role server -port 10022\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s -role client -url http://192.168.1.100:10022\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s -role client -url http://192.168.1.100:10022 -c \"Get-Location\"\n", os.Args[0])
}
flag.Parse()
// 确定角色
if role == "" {
// 检查是否有位置参数
args := flag.Args()
if len(args) > 0 {
if args[0] == "server" || args[0] == "client" {
role = args[0]
flag.CommandLine.Parse(args[1:])
} else if strings.HasPrefix(args[0], "http://") || strings.HasPrefix(args[0], "https://") {
// 兼容旧版客户端用法: http_shell_cli.exe http://ip:port
role = "client"
clientURL = args[0]
if len(args) > 2 && args[1] == "-c" {
clientCommand = args[2]
}
}
}
}
if role == "" {
role = selectRole()
}
switch role {
case "server", "s":
runServer(serverHost, serverPort, serverTimeout)
case "client", "c":
if clientURL == "" {
clientURL = promptForServerURL()
}
client := NewShellClient(clientURL, serverTimeout)
if clientWorkDir != "" {
client.WorkDir = clientWorkDir
}
if clientCommand != "" {
exitCode := runOnce(client, clientCommand)
os.Exit(exitCode)
} else {
shell := NewInteractiveShell(client)
shell.run()
}
default:
fmt.Fprintf(os.Stderr, "错误: 无效的角色 '%s',请使用 server 或 client\n", role)
os.Exit(1)
}
}
func promptForServerURL() string {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print("请输入服务端地址 (例如 http://192.168.100.55:10022): ")
input, err := reader.ReadString('\n')
if err != nil {
fmt.Fprintf(os.Stderr, "读取输入失败: %v\n", err)
os.Exit(1)
}
input = strings.TrimSpace(input)
if input == "" {
fmt.Println("地址不能为空,请重新输入")
continue
}
if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") {
input = "http://" + input
}
fmt.Printf("正在连接 %s ...\n", input)
testClient := NewShellClient(input, 5)
health := testClient.checkHealth()
if status, ok := health["status"].(string); ok && status == "running" {
shell, _ := health["shell"].(string)
fmt.Printf("%s✓ 连接成功%s | Shell: %s%s%s\n", colors.Green, colors.Reset, colors.Yellow, shell, colors.Reset)
return input
}
stderr, _ := health["stderr"].(string)
if stderr == "" {
stderr = "无法连接到服务端"
}
fmt.Printf("%s✗ 连接失败: %s%s\n", colors.Red, stderr, colors.Reset)
fmt.Print("是否重新输入? (y/n): ")
retry, _ := reader.ReadString('\n')
retry = strings.TrimSpace(strings.ToLower(retry))
if retry == "n" || retry == "no" || retry == "q" || retry == "quit" {
os.Exit(1)
}
}
}
func getEnv(key, defaultValue string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultValue
}
func getEnvInt(key string, defaultValue int) int {
if v := os.Getenv(key); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return defaultValue
}
http_shell_cli_unix.go
//go:build !windows
package main
import "os/exec"
func enableWindowsVT() {
// no-op on non-Windows
}
func windowsGbkToUTF8(data []byte) string {
// no-op on non-Windows
return string(data)
}
func setHideWindow(cmd *exec.Cmd) {
// no-op on non-Windows
}
http_shell_cli_windows.go
//go:build windows
package main
import (
"os"
"os/exec"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
func enableWindowsVT() {
handle := windows.Handle(os.Stdout.Fd())
var mode uint32
if err := windows.GetConsoleMode(handle, &mode); err == nil {
windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING)
}
}
func windowsGbkToUTF8(data []byte) string {
kernel32 := windows.NewLazySystemDLL("kernel32.dll")
multiByteToWideChar := kernel32.NewProc("MultiByteToWideChar")
wideCharToMultiByte := kernel32.NewProc("WideCharToMultiByte")
const cp936 = 936
const cpUTF8 = 65001
wideLen, _, _ := multiByteToWideChar.Call(uintptr(cp936), 0, uintptr(unsafe.Pointer(&data[0])), uintptr(len(data)), 0, 0)
if wideLen == 0 {
return string(data)
}
wideBuf := make([]uint16, wideLen)
_, _, _ = multiByteToWideChar.Call(uintptr(cp936), 0, uintptr(unsafe.Pointer(&data[0])), uintptr(len(data)),
uintptr(unsafe.Pointer(&wideBuf[0])), wideLen)
utf8Len, _, _ := wideCharToMultiByte.Call(uintptr(cpUTF8), 0, uintptr(unsafe.Pointer(&wideBuf[0])), wideLen, 0, 0, 0, 0)
if utf8Len == 0 {
return string(data)
}
utf8Buf := make([]byte, utf8Len)
_, _, _ = wideCharToMultiByte.Call(uintptr(cpUTF8), 0, uintptr(unsafe.Pointer(&wideBuf[0])), wideLen,
uintptr(unsafe.Pointer(&utf8Buf[0])), utf8Len, 0, 0)
return string(utf8Buf)
}
func setHideWindow(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
}
输出:
已完成扩展,以下是变更摘要:
新增接口
接口 方法 说明
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
/upload POST 通过 multipart/form-data 上传文件
/download POST 通过 multipart/form-data 请求下载文件
请求参数
• /upload: file (文件字段,必填), path (服务端保存路径,可选)
• path 以 / 或 \ 结尾 → 视为目录,自动创建,保留原文件名
• path 为已存在目录 → 保存到该目录下,保留原文件名
• path 为文件路径 → 保存为指定文件名,自动创建父目录
• /download: path (服务端文件路径,必填)
• 返回 application/octet-stream,带 Content-Disposition: attachment
客户端交互模式新增内置命令
• upload <本地路径> [远程路径] — 上传文件
• download <远程路径> [本地路径] — 下载文件
跨平台编译修复
将 golang.org/x/sys/windows 依赖拆分到 http_shell_cli_windows.go(//go:build w
indows)和 http_shell_cli_unix.go(//go:build !windows),现在可以正常为 Windo
ws、Linux、macOS 交叉编译。
编译产物
http_shell_cli.exe # Windows
http_shell_cli_linux # Linux
http_shell_cli_mac # macOS
测试结果
服务端启动后,curl 测试 upload/download 均正常工作:
• 上传文件到目录(带/不带尾部斜杠)✓
• 上传文件到指定路径 ✓
• 下载文件 ✓

浙公网安备 33010602011771号