折腾笔记[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方案

在离线工控机场景中,传统的远程管理方式面临多重限制:

  1. 无法安装 SSH 服务:工控机运行封闭系统,禁止安装第三方软件
  2. 无管理员权限:无法启用 Windows 自带的 OpenSSH 服务器
  3. 网络策略限制:防火墙仅开放特定端口,SSH 默认 22 端口常被封锁
  4. 跨平台需求:需要同时支持 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, ? 显示帮助

分析

设计权衡

  1. 无认证 vs 安全性:本服务设计用于离线内网环境,不提供身份认证。这是有意为之的权衡——在封闭工控网络中,物理隔离本身就是安全边界。如需增强安全性,可在前端增加反向代理(如 Nginx)配置 Basic Auth 或 IP 白名单。

  2. 单文件 vs 模块化:将所有功能编译为单个可执行文件,牺牲代码复用性换取部署便利性。工控环境通常禁止安装运行时(如 Python、.NET),单文件 Go 程序是最佳折中。

  3. HTTP vs HTTPS:内网环境使用明文 HTTP,避免证书管理的复杂度。如需加密,建议通过 VPN 或专线传输,而非在应用层处理 TLS。

关键技术点

  1. 编码自适应:Windows CMD 默认输出 GBK 编码,PowerShell 使用 UTF-8。程序通过 MultiByteToWideChar / WideCharToMultiByte Win32 API 实现 GBK→UTF-8 的实时转换,确保中文输出不乱码。

  2. 超时控制:使用 select + time.After 实现命令级超时,超时后主动 Kill 进程。这比依赖 shell 内置超时更可靠,因为能处理进程僵死的情况。

  3. Shell 自动检测:按 pwsh → powershell → cmd 优先级检测 Windows Shell,按 bash → sh → zsh 优先级检测 Linux Shell,确保在不同环境中都能正常工作。

  4. CORS 支持:服务端响应头包含 Access-Control-Allow-Origin: *,允许浏览器端直接调用 API,方便开发 Web 管理界面。

适用场景与局限

适用场景 不适用场景
离线内网工控机远程维护 公网暴露的生产环境
无法安装 SSH 的封闭系统 需要文件上传/下载的场景
临时性的命令执行需求 高并发批量命令执行
跨平台(Windows/Linux)统一工具 需要交互式 TUI 程序(如 vim)

扩展建议

  1. 文件传输:可扩展 /upload/download 接口,通过 multipart/form-data 实现文件传输
  2. 会话保持:当前每个命令独立执行,可通过 WebSocket 实现真正的交互式会话(支持 vim、top 等 TUI 程序)
  3. 命令审计:服务端日志已记录时间、IP、命令内容、执行结果,可直接对接日志分析系统
  4. 权限控制:可扩展基于 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 &lt;本地路径&gt; [远程路径], download &lt;远程路径&gt; [本地路径]</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 均正常工作:
  • 上传文件到目录(带/不带尾部斜杠)✓
  • 上传文件到指定路径 ✓
  • 下载文件 ✓
posted @ 2026-05-10 08:59  qsBye  阅读(5)  评论(0)    收藏  举报