diff --git a/api.go b/api.go new file mode 100644 index 0000000..93e339b --- /dev/null +++ b/api.go @@ -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, + }) +} diff --git a/cli.go b/cli.go new file mode 100644 index 0000000..8508142 --- /dev/null +++ b/cli.go @@ -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 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 ", + 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 []", + 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) +} diff --git a/cmd/main.go b/cmd/main.go index 6080aff..f5c2456 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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() } diff --git a/go.mod b/go.mod index 65c96f3..72cd499 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 63bdc28..5cc8614 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/jsonrpc.go b/jsonrpc.go index 01b9424..585a196 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -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"}}, diff --git a/main.go b/main.go index b1f871a..d24bdf6 100644 --- a/main.go +++ b/main.go @@ -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 != "" { diff --git a/mcp.go b/mcp.go new file mode 100644 index 0000000..4e0e615 --- /dev/null +++ b/mcp.go @@ -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 +} diff --git a/video.go b/video.go index 94aded1..04932df 100644 --- a/video.go +++ b/video.go @@ -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