feat: add support for MCP service and CLI subcommands

Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
This commit is contained in:
luckfox-eng29
2026-05-08 11:26:52 +08:00
parent bf84660c8b
commit d47bca1940
9 changed files with 1344 additions and 102 deletions

541
cli.go Normal file
View File

@@ -0,0 +1,541 @@
package kvm
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/spf13/cobra"
)
var cliRootCmd = &cobra.Command{
Use: "kvm_app cli",
Short: "PicoKVM CLI tools",
Long: `Command line interface for controlling HID devices via API`,
SilenceErrors: true,
SilenceUsage: true,
}
func RunCLI(args []string) {
cliRootCmd.SetArgs(args)
if err := cliRootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var apiBaseURL = "http://localhost:8080/api/lan"
func apiPost(path string, body interface{}) error {
jsonBody, err := json.Marshal(body)
if err != nil {
return err
}
resp, err := http.Post(apiBaseURL+path, "application/json", strings.NewReader(string(jsonBody)))
if err != nil {
return fmt.Errorf("failed to connect to API server: %w\nIs kvm_app running?", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
bodyBytes, _ := io.ReadAll(resp.Body)
var errResp map[string]interface{}
if json.Unmarshal(bodyBytes, &errResp) == nil {
if msg, ok := errResp["error"].(string); ok {
return fmt.Errorf("API error: %s", msg)
}
}
return fmt.Errorf("API error: HTTP %d - %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
func apiGetBytes(path string) ([]byte, error) {
resp, err := http.Get(apiBaseURL + path)
if err != nil {
return nil, fmt.Errorf("failed to connect to API server: %w\nIs kvm_app running?", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error: HTTP %d - %s", resp.StatusCode, string(bodyBytes))
}
return io.ReadAll(resp.Body)
}
func apiGetJSON(path string) (map[string]interface{}, error) {
resp, err := http.Get(apiBaseURL + path)
if err != nil {
return nil, fmt.Errorf("failed to connect to API server: %w\nIs kvm_app running?", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error: HTTP %d - %s", resp.StatusCode, string(bodyBytes))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func ShowHelp() {
fmt.Println(`PicoKVM - Remote KVM over IP
Usage:
kvm_app Start the main web service (default)
kvm_app cli <command> Run CLI commands for HID control
Commands:
cli HID control via API (mouse, keyboard, capture, status, signer)
Examples:
kvm_app cli mouse move 100 200
kvm_app cli mouse click left
kvm_app cli keyboard type "Hello World"
kvm_app cli capture
kvm_app cli status
kvm_app cli signer keygen
kvm_app cli signer sign --key ota_ed25519.key firmware.bin
kvm_app cli signer verify --pubkey ota_ed25519.pub firmware.bin
For more help on a specific command, use:
kvm_app cli --help`)
}
// === Mouse Commands ===
var mouseCmd = &cobra.Command{
Use: "mouse",
Short: "Mouse control commands",
}
var mouseMoveCmd = &cobra.Command{
Use: "move [x] [y]",
Short: "Move mouse to position",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
x, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("invalid x coordinate: %w", err)
}
y, err := strconv.Atoi(args[1])
if err != nil {
return fmt.Errorf("invalid y coordinate: %w", err)
}
relative, _ := cmd.Flags().GetBool("relative")
if relative {
return apiPost("/mouse/relative", map[string]interface{}{
"dx": x, "dy": y,
})
}
return apiPost("/mouse/absolute", map[string]interface{}{
"x": x, "y": y,
})
},
}
var mouseClickCmd = &cobra.Command{
Use: "click [button]",
Short: "Click a mouse button",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return apiPost("/mouse/click", map[string]interface{}{
"button": args[0],
})
},
}
var mouseScrollCmd = &cobra.Command{
Use: "scroll [delta]",
Short: "Scroll mouse wheel",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
delta, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("invalid delta: %w", err)
}
return apiPost("/mouse/scroll", map[string]interface{}{
"delta": delta,
})
},
}
var mouseDownCmd = &cobra.Command{
Use: "down [button]",
Short: "Press and hold a mouse button",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// API doesn't support hold yet, simulate with click
return apiPost("/mouse/click", map[string]interface{}{
"button": args[0],
})
},
}
var mouseUpCmd = &cobra.Command{
Use: "up",
Short: "Release all mouse buttons",
RunE: func(cmd *cobra.Command, args []string) error {
// API doesn't support hold yet, no-op
return nil
},
}
// === Keyboard Commands ===
var keyboardCmd = &cobra.Command{
Use: "keyboard",
Short: "Keyboard control commands",
}
var keyboardKeyCmd = &cobra.Command{
Use: "key [keyname]",
Short: "Press a key",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return apiPost("/keyboard/key", map[string]interface{}{
"key": args[0],
})
},
}
var keyboardComboCmd = &cobra.Command{
Use: "combo [keys...]",
Short: "Press a key combination",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return apiPost("/keyboard/combo", map[string]interface{}{
"keys": args,
})
},
}
var keyboardTypeCmd = &cobra.Command{
Use: "type [text]",
Short: "Type a text string",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return apiPost("/keyboard/type", map[string]interface{}{
"text": args[0],
})
},
}
// === Capture Command ===
var captureScreenshotPath = "/userdata/picokvm/screenshot/kvm_screenshot.jpg"
var captureCmd = &cobra.Command{
Use: "capture",
Short: "Capture a JPEG screenshot",
Long: "Capture screenshot using hardware JPEG encoder and save to fixed path",
RunE: func(cmd *cobra.Command, args []string) error {
data, err := apiGetBytes("/capture")
if err != nil {
return err
}
if err := os.WriteFile(captureScreenshotPath, data, 0644); err != nil {
return fmt.Errorf("failed to write: %w", err)
}
fmt.Printf("Screenshot saved to %s (%d bytes)\n", captureScreenshotPath, len(data))
return nil
},
}
// === Status Command ===
var statusCmd = &cobra.Command{
Use: "status",
Short: "Show device status",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiGetJSON("/video/state")
if err != nil {
return err
}
fmt.Printf("Video State:\n")
fmt.Printf(" Ready: %v\n", resp["ready"])
fmt.Printf(" Error: %s\n", resp["error"])
fmt.Printf(" Size: %dx%d\n", int(resp["width"].(float64)), int(resp["height"].(float64)))
fmt.Printf(" FPS: %.1f\n", resp["fps"])
return nil
},
}
// === Key Maps ===
var keyNameToCode = map[string]uint8{
"a": 0x04, "b": 0x05, "c": 0x06, "d": 0x07,
"e": 0x08, "f": 0x09, "g": 0x0A, "h": 0x0B,
"i": 0x0C, "j": 0x0D, "k": 0x0E, "l": 0x0F,
"m": 0x10, "n": 0x11, "o": 0x12, "p": 0x13,
"q": 0x14, "r": 0x15, "s": 0x16, "t": 0x17,
"u": 0x18, "v": 0x19, "w": 0x1A, "x": 0x1B,
"y": 0x1C, "z": 0x1D,
"1": 0x1E, "2": 0x1F, "3": 0x20, "4": 0x21,
"5": 0x22, "6": 0x23, "7": 0x24, "8": 0x25,
"9": 0x26, "0": 0x27,
"Enter": 0x28,
"Escape": 0x29,
"Backspace": 0x2A,
"Tab": 0x2B,
"Space": 0x2C,
"Minus": 0x2D,
"Equal": 0x2E,
"LeftBrace": 0x2F,
"RightBrace": 0x30,
"Backslash": 0x31,
"Semicolon": 0x33,
"Quote": 0x34,
"Grave": 0x35,
"Comma": 0x36,
"Dot": 0x37,
"Slash": 0x38,
"CapsLock": 0x39,
"F1": 0x3A, "F2": 0x3B, "F3": 0x3C, "F4": 0x3D,
"F5": 0x3E, "F6": 0x3F, "F7": 0x40, "F8": 0x41,
"F9": 0x42, "F10": 0x43, "F11": 0x44, "F12": 0x45,
"PrintScreen": 0x46,
"ScrollLock": 0x47,
"Pause": 0x48,
"Insert": 0x49,
"Home": 0x4A,
"PageUp": 0x4B,
"Delete": 0x4C,
"End": 0x4D,
"PageDown": 0x4E,
"Right": 0x4F,
"Left": 0x50,
"Down": 0x51,
"Up": 0x52,
}
func charToKeyCode(char uint8) (keyCode uint8, modifier uint8, ok bool) {
if char >= 'a' && char <= 'z' {
return keyNameToCode[string(char)], 0, true
}
if char >= 'A' && char <= 'Z' {
return keyNameToCode[strings.ToLower(string(char))], 0x02, true
}
if char >= '0' && char <= '9' {
return keyNameToCode[string(char)], 0, true
}
if char == ' ' {
return 0x2C, 0, true
}
switch char {
case '-':
return 0x2D, 0, true
case '=':
return 0x2E, 0, true
case '[':
return 0x2F, 0, true
case ']':
return 0x30, 0, true
case '\\':
return 0x31, 0, true
case ';':
return 0x33, 0, true
case '\'':
return 0x34, 0, true
case '`':
return 0x35, 0, true
case ',':
return 0x36, 0, true
case '.':
return 0x37, 0, true
case '/':
return 0x38, 0, true
}
return 0, 0, false
}
var signerCmd = &cobra.Command{
Use: "signer",
Short: "OTA firmware signing tool",
Long: "Generate keys, sign, and verify OTA firmware using Ed25519",
}
var signerKeygenCmd = &cobra.Command{
Use: "keygen",
Short: "Generate Ed25519 key pair",
RunE: func(cmd *cobra.Command, args []string) error {
outputDir, _ := cmd.Flags().GetString("output-dir")
pubKeyPath := filepath.Join(outputDir, "ota_ed25519.pub")
privKeyPath := filepath.Join(outputDir, "ota_ed25519.key")
if _, err := os.Stat(privKeyPath); err == nil {
return fmt.Errorf("private key already exists at %s, refusing to overwrite", privKeyPath)
}
publicKey, privateKey, err := ed25519.GenerateKey(nil)
if err != nil {
return fmt.Errorf("generating key pair: %w", err)
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("creating output directory: %w", err)
}
if err := os.WriteFile(privKeyPath, privateKey, 0600); err != nil {
return fmt.Errorf("writing private key: %w", err)
}
if err := os.WriteFile(pubKeyPath, publicKey, 0644); err != nil {
return fmt.Errorf("writing public key: %w", err)
}
fmt.Println(hex.EncodeToString(publicKey))
fmt.Fprintf(os.Stderr, "Private key: %s\n", privKeyPath)
fmt.Fprintf(os.Stderr, "Public key: %s\n", pubKeyPath)
return nil
},
}
var signerSignCmd = &cobra.Command{
Use: "sign --key <private-key-path> <firmware-file>",
Short: "Sign a firmware file",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
keyPath, _ := cmd.Flags().GetString("key")
filePath := args[0]
if keyPath == "" {
return fmt.Errorf("--key is required")
}
privateKey, err := os.ReadFile(keyPath)
if err != nil {
return fmt.Errorf("reading private key: %w", err)
}
if len(privateKey) != ed25519.PrivateKeySize {
return fmt.Errorf("invalid private key size: got %d bytes, expected %d", len(privateKey), ed25519.PrivateKeySize)
}
fileData, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("reading firmware file: %w", err)
}
signature := ed25519.Sign(ed25519.PrivateKey(privateKey), fileData)
sigPath := filePath + ".sig"
if err := os.WriteFile(sigPath, signature, 0644); err != nil {
return fmt.Errorf("writing signature: %w", err)
}
hash := sha256.Sum256(fileData)
fmt.Println(hex.EncodeToString(hash[:]))
fmt.Fprintf(os.Stderr, "Signature written to: %s\n", sigPath)
return nil
},
}
var signerVerifyCmd = &cobra.Command{
Use: "verify --pubkey <pubkey-path-or-hex> <firmware-file> [<sig-file>]",
Short: "Verify firmware signature",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
pubKeyArg, _ := cmd.Flags().GetString("pubkey")
filePath := args[0]
sigPath := ""
if len(args) > 1 {
sigPath = args[1]
}
if pubKeyArg == "" {
return fmt.Errorf("--pubkey is required")
}
if sigPath == "" {
sigPath = filePath + ".sig"
}
var publicKey ed25519.PublicKey
if _, err := os.Stat(pubKeyArg); err == nil {
keyBytes, err := os.ReadFile(pubKeyArg)
if err != nil {
return fmt.Errorf("reading public key file: %w", err)
}
publicKey = ed25519.PublicKey(keyBytes)
} else {
keyBytes, err := hex.DecodeString(pubKeyArg)
if err != nil {
return fmt.Errorf("decoding public key hex: %w", err)
}
publicKey = ed25519.PublicKey(keyBytes)
}
if len(publicKey) != ed25519.PublicKeySize {
return fmt.Errorf("invalid public key size: got %d bytes, expected %d", len(publicKey), ed25519.PublicKeySize)
}
fileData, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("reading firmware file: %w", err)
}
sigBytes, err := os.ReadFile(sigPath)
if err != nil {
return fmt.Errorf("reading signature file: %w", err)
}
if len(sigBytes) != ed25519.SignatureSize {
return fmt.Errorf("invalid signature size: got %d bytes, expected %d", len(sigBytes), ed25519.SignatureSize)
}
if !ed25519.Verify(publicKey, fileData, sigBytes) {
return fmt.Errorf("VERIFICATION FAILED: signature is invalid")
}
hash := sha256.Sum256(fileData)
fmt.Fprintf(os.Stderr, "VERIFICATION OK: signature is valid\n")
fmt.Fprintf(os.Stderr, "SHA256: %s\n", hex.EncodeToString(hash[:]))
return nil
},
}
func init() {
mouseMoveCmd.Flags().Bool("relative", false, "Use relative positioning")
mouseCmd.AddCommand(mouseMoveCmd)
mouseCmd.AddCommand(mouseClickCmd)
mouseCmd.AddCommand(mouseScrollCmd)
mouseCmd.AddCommand(mouseDownCmd)
mouseCmd.AddCommand(mouseUpCmd)
cliRootCmd.AddCommand(mouseCmd)
keyboardCmd.AddCommand(keyboardKeyCmd)
keyboardCmd.AddCommand(keyboardComboCmd)
keyboardCmd.AddCommand(keyboardTypeCmd)
cliRootCmd.AddCommand(keyboardCmd)
cliRootCmd.AddCommand(captureCmd)
signerKeygenCmd.Flags().String("output-dir", ".", "Directory for key output")
signerCmd.AddCommand(signerKeygenCmd)
signerSignCmd.Flags().String("key", "", "Path to private key")
signerCmd.AddCommand(signerSignCmd)
signerVerifyCmd.Flags().String("pubkey", "", "Path to public key file or hex-encoded public key")
signerCmd.AddCommand(signerVerifyCmd)
cliRootCmd.AddCommand(signerCmd)
cliRootCmd.AddCommand(statusCmd)
}