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

389
api.go Normal file
View File

@@ -0,0 +1,389 @@
package kvm
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func StartAPIServer(port int) {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
r.Use(corsMiddleware())
r.Use(apiKeyAuthMiddleware(config.APIKey))
// Health check (no auth required)
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"device": GetDeviceID(),
"version": builtAppVersion,
})
})
// API routes
api := r.Group("/api/lan")
{
// Mouse
api.POST("/mouse/absolute", handleAPIMouseAbsolute)
api.POST("/mouse/relative", handleAPIMouseRelative)
api.POST("/mouse/click", handleAPIMouseClick)
api.POST("/mouse/scroll", handleAPIMouseScroll)
// Keyboard
api.POST("/keyboard/key", handleAPIKeyboardKey)
api.POST("/keyboard/combo", handleAPIKeyboardCombo)
api.POST("/keyboard/type", handleAPIKeyboardType)
// Capture
api.GET("/capture", handleAPICapture)
// Status
api.GET("/status", handleAPIStatus)
api.GET("/video/state", handleAPIVideoState)
}
addr := fmt.Sprintf(":%d", port)
logger.Info().Str("addr", addr).Msg("Starting LAN API server")
if err := r.Run(addr); err != nil {
logger.Error().Err(err).Msg("LAN API server failed")
}
}
// === Middleware ===
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
func apiKeyAuthMiddleware(expectedKey string) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip auth for health endpoint
if c.Request.URL.Path == "/health" {
c.Next()
return
}
// Skip auth for localhost
remoteAddr := c.Request.RemoteAddr
if strings.HasPrefix(remoteAddr, "127.0.0.1:") ||
strings.HasPrefix(remoteAddr, "[::1]:") {
c.Next()
return
}
// If no API key configured, reject LAN requests
if expectedKey == "" {
c.AbortWithStatusJSON(401, gin.H{"error": "API key not configured"})
return
}
auth := c.GetHeader("Authorization")
var key string
if _, err := fmt.Sscanf(auth, "Bearer %s", &key); err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "missing or invalid authorization"})
return
}
if !strings.EqualFold(key, expectedKey) {
c.AbortWithStatusJSON(401, gin.H{"error": "invalid api key"})
return
}
c.Next()
}
}
// === API Handlers: Mouse ===
func handleAPIMouseAbsolute(c *gin.Context) {
var req struct {
X int `json:"x" binding:"required"`
Y int `json:"y" binding:"required"`
Buttons uint8 `json:"buttons"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
"x": req.X, "y": req.Y, "buttons": req.Buttons,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "x": req.X, "y": req.Y})
}
func handleAPIMouseRelative(c *gin.Context) {
var req struct {
Dx int8 `json:"dx" binding:"required"`
Dy int8 `json:"dy" binding:"required"`
Buttons uint8 `json:"buttons"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := callRPCHandler(rpcHandlers["relMouseReport"], map[string]interface{}{
"dx": req.Dx, "dy": req.Dy, "buttons": req.Buttons,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "dx": req.Dx, "dy": req.Dy})
}
func handleAPIMouseClick(c *gin.Context) {
var req struct {
Button string `json:"button" binding:"required"`
Double bool `json:"double"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var buttons uint8
switch req.Button {
case "left":
buttons = 1
case "right":
buttons = 2
case "middle":
buttons = 4
default:
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown button: %s", req.Button)})
return
}
clickCount := 1
if req.Double {
clickCount = 2
}
for i := 0; i < clickCount; i++ {
_, err := callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
"x": 0, "y": 0, "buttons": buttons,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_, err = callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
"x": 0, "y": 0, "buttons": 0,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "button": req.Button, "clicks": clickCount})
}
func handleAPIMouseScroll(c *gin.Context) {
var req struct {
Delta int8 `json:"delta" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := callRPCHandler(rpcHandlers["wheelReport"], map[string]interface{}{
"wheelY": req.Delta,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "delta": req.Delta})
}
// === API Handlers: Keyboard ===
func handleAPIKeyboardKey(c *gin.Context) {
var req struct {
Key string `json:"key" binding:"required"`
Action string `json:"action"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
keyCode, ok := keyNameToCode[req.Key]
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown key: %s", req.Key)})
return
}
action := strings.ToLower(req.Action)
if action == "" {
action = "press"
}
switch action {
case "press":
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{keyCode},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
case "down":
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{keyCode},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
case "up":
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown action: %s", req.Action)})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "key": req.Key, "action": action})
}
func handleAPIKeyboardCombo(c *gin.Context) {
var req struct {
Keys []string `json:"keys" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var keys []uint8
var modifier uint8
for _, keyName := range req.Keys {
switch strings.ToLower(keyName) {
case "ctrl", "control":
modifier |= 0x01
continue
case "shift":
modifier |= 0x02
continue
case "alt":
modifier |= 0x04
continue
case "meta", "win", "cmd":
modifier |= 0x08
continue
}
keyCode, ok := keyNameToCode[keyName]
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown key: %s", keyName)})
return
}
keys = append(keys, keyCode)
}
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": modifier, "keys": keys,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "keys": req.Keys})
}
func handleAPIKeyboardType(c *gin.Context) {
var req struct {
Text string `json:"text" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
for _, char := range req.Text {
keyCode, modifier, ok := charToKeyCode(uint8(char))
if !ok {
continue
}
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": modifier, "keys": []uint8{keyCode},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "text": req.Text})
}
// === API Handlers: Capture ===
func handleAPICapture(c *gin.Context) {
data, err := captureScreenshot("jpeg")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "image/jpeg", data)
}
// === API Handlers: Status ===
func handleAPIStatus(c *gin.Context) {
state := lastVideoState
c.JSON(http.StatusOK, gin.H{
"video": gin.H{
"ready": state.Ready,
"width": state.Width,
"height": state.Height,
"fps": state.FramePerSecond,
"error": state.Error,
},
"device": gin.H{
"name": "PicoKVM",
"version": builtAppVersion,
},
})
}
func handleAPIVideoState(c *gin.Context) {
state := lastVideoState
c.JSON(http.StatusOK, gin.H{
"ready": state.Ready,
"width": state.Width,
"height": state.Height,
"fps": state.FramePerSecond,
"error": state.Error,
})
}

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)
}

View File

@@ -1,9 +1,15 @@
package main
import (
"os"
"kvm"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "cli" {
kvm.RunCLI(os.Args[2:])
return
}
kvm.Main()
}

25
go.mod
View File

@@ -1,14 +1,11 @@
module kvm
go 1.23.4
toolchain go1.24.3
go 1.25.5
require (
github.com/Masterminds/semver/v3 v3.3.1
github.com/beevik/ntp v1.4.3
github.com/coder/websocket v1.8.13
github.com/coreos/go-oidc/v3 v3.11.0
github.com/creack/pty v1.1.23
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-contrib/logger v1.2.6
@@ -17,8 +14,10 @@ require (
github.com/guregu/null/v6 v6.0.0
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf
github.com/hanwen/go-fuse/v2 v2.8.0
github.com/mark3labs/mcp-go v0.52.0
github.com/pion/logging v0.2.3
github.com/pion/mdns/v2 v2.0.7
github.com/pion/rtp v1.8.18
github.com/pion/webrtc/v4 v4.0.16
github.com/pojntfx/go-nbd v0.3.2
github.com/prometheus/client_golang v1.22.0
@@ -27,6 +26,7 @@ require (
github.com/psanford/httpreadat v0.1.0
github.com/rs/zerolog v1.34.0
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.10.0
github.com/vishvananda/netlink v1.3.0
go.bug.st/serial v1.6.2
@@ -47,11 +47,12 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@@ -63,35 +64,29 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pilebones/go-udev v0.9.0 // indirect
github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v2 v2.2.12 // indirect
github.com/pion/dtls/v3 v3.0.6 // indirect
github.com/pion/ice/v2 v2.3.36 // indirect
github.com/pion/ice/v4 v4.0.10 // indirect
github.com/pion/interceptor v0.1.40 // indirect
github.com/pion/mdns v0.0.12 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/rtp v1.8.18 // indirect
github.com/pion/sctp v1.8.39 // indirect
github.com/pion/sdp/v3 v3.0.13 // indirect
github.com/pion/srtp/v2 v2.0.20 // indirect
github.com/pion/srtp/v3 v3.0.5 // indirect
github.com/pion/stun v0.6.1 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v2 v2.2.10 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v2 v2.1.6 // indirect
github.com/pion/turn/v4 v4.0.2 // indirect
github.com/pion/webrtc/v3 v3.3.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/arch v0.17.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

109
go.sum
View File

@@ -18,9 +18,8 @@ github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
@@ -28,6 +27,10 @@ github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
@@ -38,8 +41,6 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -54,7 +55,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
@@ -63,6 +65,8 @@ github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -71,7 +75,6 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -83,6 +86,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mark3labs/mcp-go v0.52.0 h1:uRSzupNSUyPGDpF4owY5X4zEpACPwBnlM3FAFuXN6gQ=
github.com/mark3labs/mcp-go v0.52.0/go.mod h1:Zg9cB2HdwdMMVgY0xtTzq3KvYIOJQDsaut+jWjwDaQY=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -105,59 +110,34 @@ github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl
github.com/pilebones/go-udev v0.9.0/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI=
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
github.com/pion/ice/v2 v2.3.36 h1:SopeXiVbbcooUg2EIR8sq4b13RQ8gzrkkldOVg+bBsc=
github.com/pion/ice/v2 v2.3.36/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4=
github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk=
github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo=
github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM=
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
github.com/pion/webrtc/v3 v3.3.5 h1:ZsSzaMz/i9nblPdiAkZoP+E6Kmjw+jnyq3bEmU3EtRg=
github.com/pion/webrtc/v3 v3.3.5/go.mod h1:liNa+E1iwyzyXqNUwvoMRNQ10x8h8FOeJKL8RkIbamE=
github.com/pion/webrtc/v4 v4.0.16 h1:5f8QMVIbNvJr2mPRGi2QamkPa/LVUB6NWolOCwphKHA=
github.com/pion/webrtc/v4 v4.0.16/go.mod h1:C3uTCPzVafUA0eUzru9f47OgNt3nEO7ZJ6zNY6VSJno=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -178,8 +158,17 @@ github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUz
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU=
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -188,8 +177,6 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -200,81 +187,31 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8=
go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1496,6 +1496,61 @@ func rpcConfirmOtherSession() (bool, error) {
return true, nil
}
const jpegScreenshotPath = "/userdata/picokvm/screenshot/kvm_screenshot.jpg"
// StartJpegCapture starts continuous JPEG capture mode.
func StartJpegCapture() error {
_, err := CallCtrlAction("jpeg_capture_start", nil)
if err != nil {
return fmt.Errorf("failed to start JPEG capture: %w", err)
}
return nil
}
// StopJpegCapture stops continuous JPEG capture mode.
func StopJpegCapture() error {
_, err := CallCtrlAction("jpeg_capture_stop", nil)
if err != nil {
return fmt.Errorf("failed to stop JPEG capture: %w", err)
}
return nil
}
// captureScreenshot captures a JPEG screenshot using hardware encoder.
func captureScreenshot(format string) ([]byte, error) {
if format != "jpeg" && format != "jpg" {
return nil, fmt.Errorf("only JPEG format is supported")
}
logger.Info().Msg("triggering JPEG snapshot via jpeg_take_snapshot")
if err := os.MkdirAll("/userdata/picokvm/screenshot", 0o755); err != nil {
logger.Warn().Err(err).Msg("failed to create screenshot directory")
}
os.Remove(jpegScreenshotPath)
resp, err := CallCtrlAction("jpeg_take_snapshot", nil)
if err != nil {
logger.Error().Err(err).Msg("jpeg_take_snapshot failed")
return nil, fmt.Errorf("failed to trigger JPEG capture: %w", err)
}
logger.Info().Interface("response", resp).Msg("jpeg_take_snapshot response")
// Poll for file with timeout
maxAttempts := 10
for i := 0; i < maxAttempts; i++ {
if data, err := os.ReadFile(jpegScreenshotPath); err == nil && len(data) > 0 {
logger.Info().Int("size", len(data)).Int("attempts", i+1).Msg("JPEG captured successfully")
return data, nil
}
time.Sleep(200 * time.Millisecond)
}
logger.Error().Str("path", jpegScreenshotPath).Msg("JPEG file not found after timeout")
return nil, fmt.Errorf("JPEG file not found at %s", jpegScreenshotPath)
}
var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing},
"reboot": {Func: rpcReboot, Params: []string{"force"}},

View File

@@ -185,6 +185,15 @@ func Main() {
//go RunFuseServer()
go RunWebServer()
// API and MCP services temporarily disabled for debugging
go func() {
StartAPIServer(8080)
}()
go func() {
StartMCP(8081, false)
}()
go RunWebSecureServer()
// Web secure server is started only if TLS mode is enabled
if config.TLSMode != "" {

310
mcp.go Normal file
View File

@@ -0,0 +1,310 @@
package kvm
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func StartMCP(port int, stdio bool) {
s := server.NewMCPServer("picokvm-mcp", "1.0.0")
registerMCPTools(s)
if stdio {
logger.Info().Msg("Starting MCP stdio server")
if err := server.ServeStdio(s); err != nil {
logger.Error().Err(err).Msg("MCP stdio server failed")
}
return
}
// SSE mode
addr := fmt.Sprintf(":%d", port)
sseServer := server.NewSSEServer(s)
handler := sseServer.SSEHandler()
// Add auth for non-localhost
if config.APIKey != "" {
handler = withAPIKeyAuth(handler, config.APIKey)
}
handler = withCORS(handler)
logger.Info().Str("addr", addr).Msg("Starting MCP SSE server")
if err := http.ListenAndServe(addr, handler); err != nil {
logger.Error().Err(err).Msg("MCP SSE server failed")
}
}
// === Shared middleware helpers ===
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(204)
return
}
next.ServeHTTP(w, r)
})
}
func withAPIKeyAuth(next http.Handler, expectedKey string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth for localhost
if strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") ||
strings.HasPrefix(r.RemoteAddr, "[::1]:") {
next.ServeHTTP(w, r)
return
}
auth := r.Header.Get("Authorization")
var key string
if _, err := fmt.Sscanf(auth, "Bearer %s", &key); err != nil {
http.Error(w, `{"error":"missing or invalid authorization"}`, http.StatusUnauthorized)
return
}
if !strings.EqualFold(key, expectedKey) {
http.Error(w, `{"error":"invalid api key"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// === MCP Tool Registration ===
func registerMCPTools(s *server.MCPServer) {
s.AddTool(mcp.NewTool("mouse_move_absolute",
mcp.WithDescription("Move mouse to absolute coordinates (0-32767)"),
mcp.WithNumber("x", mcp.Required(), mcp.Description("X coordinate")),
mcp.WithNumber("y", mcp.Required(), mcp.Description("Y coordinate")),
), handleMouseMoveAbsolute)
s.AddTool(mcp.NewTool("mouse_move_relative",
mcp.WithDescription("Move mouse by relative offset"),
mcp.WithNumber("dx", mcp.Required()),
mcp.WithNumber("dy", mcp.Required()),
), handleMouseMoveRelative)
s.AddTool(mcp.NewTool("mouse_click",
mcp.WithDescription("Click mouse button"),
mcp.WithString("button", mcp.Required(), mcp.Enum("left", "right", "middle")),
), handleMouseClick)
s.AddTool(mcp.NewTool("mouse_scroll",
mcp.WithDescription("Scroll mouse wheel"),
mcp.WithNumber("delta", mcp.Required()),
), handleMouseScroll)
s.AddTool(mcp.NewTool("keyboard_key",
mcp.WithDescription("Press a key"),
mcp.WithString("key", mcp.Required(), mcp.Description("Key name: Enter, Escape, Tab, etc.")),
), handleKeyboardKey)
s.AddTool(mcp.NewTool("keyboard_combo",
mcp.WithDescription("Press key combination"),
mcp.WithArray("keys", mcp.Required(), mcp.Items(map[string]any{"type": "string"})),
), handleKeyboardCombo)
s.AddTool(mcp.NewTool("type_text",
mcp.WithDescription("Type text string"),
mcp.WithString("text", mcp.Required()),
), handleTypeText)
s.AddTool(mcp.NewTool("capture_screenshot",
mcp.WithDescription("Capture JPEG screenshot using hardware encoder"),
), handleCaptureScreenshot)
s.AddTool(mcp.NewTool("get_video_state",
mcp.WithDescription("Get screen resolution and video status"),
), handleGetVideoState)
}
// === MCP Handlers ===
func handleMouseMoveAbsolute(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
x, _ := args["x"].(float64)
y, _ := args["y"].(float64)
_, err := callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
"x": x, "y": y, "buttons": 0,
})
if err != nil {
return nil, err
}
return mcp.NewToolResultText(fmt.Sprintf("Mouse moved to (%d, %d)", int(x), int(y))), nil
}
func handleMouseMoveRelative(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
dx, _ := args["dx"].(float64)
dy, _ := args["dy"].(float64)
_, err := callRPCHandler(rpcHandlers["relMouseReport"], map[string]interface{}{
"dx": dx, "dy": dy, "buttons": 0,
})
if err != nil {
return nil, err
}
return mcp.NewToolResultText(fmt.Sprintf("Mouse moved by (%d, %d)", int(dx), int(dy))), nil
}
func handleMouseClick(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
button, _ := args["button"].(string)
var buttons uint8
switch button {
case "left":
buttons = 1
case "right":
buttons = 2
case "middle":
buttons = 4
}
_, err := callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
"x": 0, "y": 0, "buttons": buttons,
})
if err != nil {
return nil, err
}
_, err = callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
"x": 0, "y": 0, "buttons": 0,
})
if err != nil {
return nil, err
}
return mcp.NewToolResultText(fmt.Sprintf("Clicked %s button", button)), nil
}
func handleMouseScroll(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
delta, _ := args["delta"].(float64)
_, err := callRPCHandler(rpcHandlers["wheelReport"], map[string]interface{}{
"wheelY": delta,
})
if err != nil {
return nil, err
}
return mcp.NewToolResultText(fmt.Sprintf("Scrolled by %d", int(delta))), nil
}
func handleKeyboardKey(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
keyName, _ := args["key"].(string)
keyCode, ok := keyNameToCode[keyName]
if !ok {
return nil, fmt.Errorf("unknown key: %s", keyName)
}
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{keyCode},
})
if err != nil {
return nil, err
}
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{},
})
if err != nil {
return nil, err
}
return mcp.NewToolResultText(fmt.Sprintf("Pressed key: %s", keyName)), nil
}
func handleKeyboardCombo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
keysArg, _ := args["keys"].([]interface{})
var keys []uint8
var modifier uint8
for _, k := range keysArg {
keyName, _ := k.(string)
switch strings.ToLower(keyName) {
case "ctrl", "control":
modifier |= 0x01
continue
case "shift":
modifier |= 0x02
continue
case "alt":
modifier |= 0x04
continue
case "meta", "win", "cmd":
modifier |= 0x08
continue
}
keyCode, ok := keyNameToCode[keyName]
if !ok {
return nil, fmt.Errorf("unknown key: %s", keyName)
}
keys = append(keys, keyCode)
}
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": modifier, "keys": keys,
})
if err != nil {
return nil, err
}
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{},
})
if err != nil {
return nil, err
}
return mcp.NewToolResultText(fmt.Sprintf("Pressed combo: %v", keysArg)), nil
}
func handleTypeText(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
text, _ := args["text"].(string)
for _, char := range text {
keyCode, modifier, ok := charToKeyCode(uint8(char))
if !ok {
continue
}
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": modifier, "keys": []uint8{keyCode},
})
if err != nil {
return nil, err
}
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{},
})
if err != nil {
return nil, err
}
}
return mcp.NewToolResultText(fmt.Sprintf("Typed: %s", text)), nil
}
func handleCaptureScreenshot(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
data, err := captureScreenshot("jpeg")
if err != nil {
return nil, err
}
base64Data := base64.StdEncoding.EncodeToString(data)
return mcp.NewToolResultImage("JPEG screenshot captured", base64Data, "image/jpeg"), nil
}
func handleGetVideoState(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
result, err := callRPCHandler(rpcHandlers["getVideoState"], nil)
if err != nil {
return nil, err
}
state, ok := result.(VideoInputState)
if !ok {
return nil, fmt.Errorf("unexpected video state type")
}
text := fmt.Sprintf("Video: %dx%d @ %.1f fps (Ready: %v)", state.Width, state.Height, state.FramePerSecond, state.Ready)
if state.Error != "" {
text += fmt.Sprintf(" [Error: %s]", state.Error)
}
return mcp.NewToolResultText(text), nil
}

View File

@@ -24,7 +24,7 @@ type VideoInputState struct {
Error string `json:"error,omitempty"` //no_signal, no_lock, out_of_range
Width int `json:"width"`
Height int `json:"height"`
FramePerSecond float64 `json:"fps"`
FramePerSecond float64 `json:"frame_per_second"`
}
var lastVideoState VideoInputState