Files
kvm/jsonrpc.go
T
Adam Shiervani 8f890565ce fix: reset HID file handles after disk-mode virtual media mount (#560) (#1349)
* fix: reset HID file handles after mass storage mode change triggers USB rebind (#560)

* fix: add E2E regression test for disk-mode virtual media keyboard loss (#560)

* fix: include config backup file in factory reset paths

The backup config (.bak) created by SaveBackupConfig() during config
validation failures was not removed during factory reset, potentially
leaking sensitive user data (MQTT credentials, network settings).
2026-03-27 16:00:20 +01:00

1264 lines
37 KiB
Go

package kvm
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
"sync"
"time"
"github.com/pion/webrtc/v4"
"github.com/rs/zerolog"
"github.com/jetkvm/kvm/internal/hidrpc"
"github.com/jetkvm/kvm/internal/usbgadget"
"github.com/jetkvm/kvm/internal/utils"
)
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params map[string]any `json:"params,omitempty"`
ID any `json:"id,omitempty"`
}
type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
Result any `json:"result,omitempty"`
Error any `json:"error,omitempty"`
ID any `json:"id"`
}
type JSONRPCEvent struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
type DisplayRotationSettings struct {
Rotation string `json:"rotation"`
}
type BacklightSettings struct {
MaxBrightness int `json:"max_brightness"`
DimAfter int `json:"dim_after"`
OffAfter int `json:"off_after"`
}
func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
responseBytes, err := json.Marshal(response)
if err != nil {
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC response")
return
}
err = session.RPCChannel.SendText(string(responseBytes))
if err != nil {
jsonRpcLogger.Warn().Err(err).Msg("Error sending JSONRPC response")
return
}
}
func writeJSONRPCEvent(event string, params any, session *Session) {
request := JSONRPCEvent{
JSONRPC: "2.0",
Method: event,
Params: params,
}
requestBytes, err := json.Marshal(request)
if err != nil {
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC event")
return
}
if session == nil || session.RPCChannel == nil {
jsonRpcLogger.Info().Msg("RPC channel not available")
return
}
requestString := string(requestBytes)
scopedLogger := jsonRpcLogger.With().
Str("data", requestString).
Logger()
scopedLogger.Trace().Msg("sending JSONRPC event")
err = session.RPCChannel.SendText(requestString)
if err != nil {
scopedLogger.Warn().Err(err).Msg("error sending JSONRPC event")
return
}
}
func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
var request JSONRPCRequest
err := json.Unmarshal(message.Data, &request)
if err != nil {
jsonRpcLogger.Warn().
Str("data", string(message.Data)).
Err(err).
Msg("Error unmarshalling JSONRPC request")
errorResponse := JSONRPCResponse{
JSONRPC: "2.0",
Error: map[string]any{
"code": -32700,
"message": "Parse error",
},
ID: 0,
}
writeJSONRPCResponse(errorResponse, session)
return
}
scopedLogger := jsonRpcLogger.With().
Str("method", request.Method).
Interface("params", request.Params).
Interface("id", request.ID).Logger()
scopedLogger.Trace().Msg("Received RPC request")
t := time.Now()
handler, ok := rpcHandlers[request.Method]
if !ok {
errorResponse := JSONRPCResponse{
JSONRPC: "2.0",
Error: map[string]any{
"code": -32601,
"message": "Method not found",
},
ID: request.ID,
}
writeJSONRPCResponse(errorResponse, session)
return
}
result, err := callRPCHandler(scopedLogger, handler, request.Params)
if err != nil {
scopedLogger.Error().Err(err).Msg("Error calling RPC handler")
errorResponse := JSONRPCResponse{
JSONRPC: "2.0",
Error: map[string]any{
"code": -32603,
"message": "Internal error",
"data": err.Error(),
},
ID: request.ID,
}
writeJSONRPCResponse(errorResponse, session)
return
}
scopedLogger.Trace().Dur("duration", time.Since(t)).Interface("result", result).Msg("RPC handler returned")
response := JSONRPCResponse{
JSONRPC: "2.0",
Result: result,
ID: request.ID,
}
writeJSONRPCResponse(response, session)
}
func rpcPing() (string, error) {
return "pong", nil
}
func rpcGetDeviceID() (string, error) {
return GetDeviceID(), nil
}
func rpcReboot(force bool) error {
logger.Info().Msg("Got reboot request via RPC")
return hwReboot(force, nil, 0)
}
func rpcGetStreamQualityFactor() (float64, error) {
return config.VideoQualityFactor, nil
}
func rpcSetStreamQualityFactor(factor float64) error {
logger.Info().Float64("factor", factor).Msg("Setting stream quality factor")
err := nativeInstance.VideoSetQualityFactor(factor)
if err != nil {
return err
}
config.VideoQualityFactor = factor
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcGetAutoUpdateState() (bool, error) {
return config.AutoUpdateEnabled, nil
}
func rpcSetAutoUpdateState(enabled bool) (bool, error) {
config.AutoUpdateEnabled = enabled
if err := SaveConfig(); err != nil {
return config.AutoUpdateEnabled, fmt.Errorf("failed to save config: %w", err)
}
return enabled, nil
}
func rpcGetEDID() (string, error) {
resp, err := nativeInstance.VideoGetEDID()
if err != nil {
return "", err
}
return resp, nil
}
func rpcSetEDID(edid string) error {
if edid == "" {
logger.Info().Msg("Restoring EDID to default")
} else {
logger.Info().Str("edid", edid).Msg("Setting EDID")
}
err := nativeInstance.VideoSetEDID(edid)
if err != nil {
return err
}
// Save EDID to config, allowing it to be restored on reboot.
config.EdidString = edid
_ = SaveConfig()
return nil
}
func rpcGetVideoLogStatus() (string, error) {
return nativeInstance.VideoLogStatus()
}
func rpcSetDisplayRotation(params DisplayRotationSettings) error {
currentRotation := config.DisplayRotation
if currentRotation == params.Rotation {
return nil
}
err := config.SetDisplayRotation(params.Rotation)
if err != nil {
return err
}
_, err = nativeInstance.DisplaySetRotation(config.GetDisplayRotation())
if err != nil {
return err
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return err
}
func rpcGetDisplayRotation() (*DisplayRotationSettings, error) {
return &DisplayRotationSettings{
Rotation: config.DisplayRotation,
}, nil
}
func rpcSetBacklightSettings(params BacklightSettings) error {
blConfig := params
// NOTE: by default, the frontend limits the brightness to 64, as that's what the device originally shipped with.
if blConfig.MaxBrightness > 255 || blConfig.MaxBrightness < 0 {
return fmt.Errorf("maxBrightness must be between 0 and 255")
}
if blConfig.DimAfter < 0 {
return fmt.Errorf("dimAfter must be a positive integer")
}
if blConfig.OffAfter < 0 {
return fmt.Errorf("offAfter must be a positive integer")
}
config.DisplayMaxBrightness = blConfig.MaxBrightness
config.DisplayDimAfterSec = blConfig.DimAfter
config.DisplayOffAfterSec = blConfig.OffAfter
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
logger.Info().Int("max_brightness", config.DisplayMaxBrightness).Int("dim_after", config.DisplayDimAfterSec).Int("off_after", config.DisplayOffAfterSec).Msg("rpc: display: settings applied")
// If the device started up with auto-dim and/or auto-off set to zero, the display init
// method will not have started the tickers. So in case that has changed, attempt to start the tickers now.
startBacklightTickers()
// Wake the display after the settings are altered, this ensures the tickers
// are reset to the new settings, and will bring the display up to maxBrightness.
// Calling with force set to true, to ignore the current state of the display, and force
// it to reset the tickers.
wakeDisplay(true, "backlight_settings_changed")
return nil
}
func rpcGetBacklightSettings() (*BacklightSettings, error) {
return &BacklightSettings{
MaxBrightness: config.DisplayMaxBrightness,
DimAfter: int(config.DisplayDimAfterSec),
OffAfter: int(config.DisplayOffAfterSec),
}, nil
}
const (
devModeFile = "/userdata/jetkvm/devmode.enable"
sshKeyDir = "/userdata/dropbear/.ssh"
sshKeyFile = "/userdata/dropbear/.ssh/authorized_keys"
)
type DevModeState struct {
Enabled bool `json:"enabled"`
}
type SSHKeyState struct {
SSHKey string `json:"sshKey"`
}
func rpcGetDevModeState() (DevModeState, error) {
devModeEnabled := false
if _, err := os.Stat(devModeFile); err != nil {
if !os.IsNotExist(err) {
return DevModeState{}, fmt.Errorf("error checking dev mode file: %w", err)
}
} else {
devModeEnabled = true
}
return DevModeState{
Enabled: devModeEnabled,
}, nil
}
func rpcSetDevModeState(enabled bool) error {
if enabled {
if _, err := os.Stat(devModeFile); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(devModeFile), 0755); err != nil {
return fmt.Errorf("failed to create directory for devmode file: %w", err)
}
if err := os.WriteFile(devModeFile, []byte{}, 0644); err != nil {
return fmt.Errorf("failed to create devmode file: %w", err)
}
} else {
logger.Debug().Msg("dev mode already enabled")
return nil
}
} else {
if _, err := os.Stat(devModeFile); err == nil {
if err := os.Remove(devModeFile); err != nil {
return fmt.Errorf("failed to remove devmode file: %w", err)
}
} else if os.IsNotExist(err) {
logger.Debug().Msg("dev mode already disabled")
return nil
} else {
return fmt.Errorf("error checking dev mode file: %w", err)
}
}
cmd := exec.Command("dropbear.sh")
output, err := cmd.CombinedOutput()
if err != nil {
logger.Warn().Err(err).Bytes("output", output).Msg("Failed to start/stop SSH")
return fmt.Errorf("failed to start/stop SSH, you may need to reboot for changes to take effect")
}
return nil
}
func rpcGetSSHKeyState() (string, error) {
keyData, err := os.ReadFile(sshKeyFile)
if err != nil {
if !os.IsNotExist(err) {
return "", fmt.Errorf("error reading SSH key file: %w", err)
}
}
return string(keyData), nil
}
func rpcSetSSHKeyState(sshKey string) error {
if sshKey == "" {
// Remove SSH key file if empty string is provided
if err := os.Remove(sshKeyFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove SSH key file: %w", err)
}
return nil
}
// Validate SSH key
if err := utils.ValidateSSHKey(sshKey); err != nil {
return err
}
// Create directory if it doesn't exist
if err := os.MkdirAll(sshKeyDir, 0700); err != nil {
return fmt.Errorf("failed to create SSH key directory: %w", err)
}
// Write SSH key to file
if err := os.WriteFile(sshKeyFile, []byte(sshKey), 0600); err != nil {
return fmt.Errorf("failed to write SSH key: %w", err)
}
return nil
}
func rpcGetTLSState() TLSState {
return getTLSState()
}
func rpcSetTLSState(state TLSState) error {
err := setTLSState(state)
if err != nil {
return fmt.Errorf("failed to set TLS state: %w", err)
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
type RPCHandler struct {
Func any
Params []string
}
// call the handler but recover from a panic to ensure our RPC thread doesn't collapse on malformed calls
func callRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (result any, err error) {
// Use defer to recover from a panic
defer func() {
if r := recover(); r != nil {
// Convert the panic to an error
if e, ok := r.(error); ok {
err = e
} else {
err = fmt.Errorf("panic occurred: %v", r)
}
}
}()
// Call the handler
result, err = riskyCallRPCHandler(logger, handler, params)
return result, err // do not combine these two lines into one, as it breaks the above defer function's setting of err
}
func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (any, error) {
handlerValue := reflect.ValueOf(handler.Func)
handlerType := handlerValue.Type()
if handlerType.Kind() != reflect.Func {
return nil, errors.New("handler is not a function")
}
numParams := handlerType.NumIn()
paramNames := handler.Params // Get the parameter names from the RPCHandler
if len(paramNames) != numParams {
err := fmt.Errorf("mismatch between handler parameters (%d) and defined parameter names (%d)", numParams, len(paramNames))
logger.Error().Strs("paramNames", paramNames).Err(err).Msg("Cannot call RPC handler")
return nil, err
}
args := make([]reflect.Value, numParams)
for i := range numParams {
paramType := handlerType.In(i)
paramName := paramNames[i]
paramValue, ok := params[paramName]
if !ok {
err := fmt.Errorf("missing parameter: %s", paramName)
logger.Error().Err(err).Msg("Cannot marshal arguments for RPC handler")
return nil, err
}
convertedValue := reflect.ValueOf(paramValue)
if !convertedValue.Type().ConvertibleTo(paramType) {
if paramType.Kind() == reflect.Slice && (convertedValue.Kind() == reflect.Slice || convertedValue.Kind() == reflect.Array) {
newSlice := reflect.MakeSlice(paramType, convertedValue.Len(), convertedValue.Len())
for j := 0; j < convertedValue.Len(); j++ {
elemValue := convertedValue.Index(j)
if elemValue.Kind() == reflect.Interface {
elemValue = elemValue.Elem()
}
if !elemValue.Type().ConvertibleTo(paramType.Elem()) {
// Handle float64 to uint8 conversion
if elemValue.Kind() == reflect.Float64 && paramType.Elem().Kind() == reflect.Uint8 {
intValue := int(elemValue.Float())
if intValue < 0 || intValue > 255 {
return nil, fmt.Errorf("value out of range for uint8: %v for parameter %s", intValue, paramName)
}
newSlice.Index(j).SetUint(uint64(intValue))
} else {
fromType := elemValue.Type()
toType := paramType.Elem()
return nil, fmt.Errorf("invalid element type in slice for parameter %s: from %v to %v", paramName, fromType, toType)
}
} else {
newSlice.Index(j).Set(elemValue.Convert(paramType.Elem()))
}
}
args[i] = newSlice
} else if paramType.Kind() == reflect.Struct && convertedValue.Kind() == reflect.Map {
jsonData, err := json.Marshal(convertedValue.Interface())
if err != nil {
return nil, fmt.Errorf("failed to marshal map to JSON: %v for parameter %s", err, paramName)
}
newStruct := reflect.New(paramType).Interface()
if err := json.Unmarshal(jsonData, newStruct); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v for parameter %s", err, paramName)
}
args[i] = reflect.ValueOf(newStruct).Elem()
} else {
return nil, fmt.Errorf("invalid parameter type for: %s, type: %s", paramName, paramType.Kind())
}
} else {
args[i] = convertedValue.Convert(paramType)
}
}
logger.Trace().Msg("Calling RPC handler")
results := handlerValue.Call(args)
if len(results) == 0 {
return nil, nil
}
if len(results) == 1 {
if ok, err := asError(results[0]); ok {
return nil, err
}
return results[0].Interface(), nil
}
if len(results) == 2 {
if ok, err := asError(results[1]); ok {
if err != nil {
return nil, err
}
}
return results[0].Interface(), nil
}
return nil, fmt.Errorf("too many return values from handler: %d", len(results))
}
func asError(value reflect.Value) (bool, error) {
if value.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
if value.IsNil() {
return true, nil
}
return true, value.Interface().(error)
}
return false, nil
}
func rpcSetMassStorageMode(mode string) (string, error) {
logger.Info().Str("mode", mode).Msg("Setting mass storage mode")
var cdrom bool
switch mode {
case "cdrom":
cdrom = true
case "file":
cdrom = false
default:
logger.Info().Str("mode", mode).Msg("Invalid mode provided")
return "", fmt.Errorf("invalid mode: %s", mode)
}
logger.Info().Str("mode", mode).Msg("Setting mass storage mode")
err := setMassStorageMode(cdrom)
if err != nil {
return "", fmt.Errorf("failed to set mass storage mode: %w", err)
}
logger.Info().Str("mode", mode).Msg("Mass storage mode set")
// Get the updated mode after setting
return rpcGetMassStorageMode()
}
func rpcGetMassStorageMode() (string, error) {
cdrom, err := getMassStorageCDROMEnabled()
if err != nil {
return "", fmt.Errorf("failed to get mass storage mode: %w", err)
}
mode := "file"
if cdrom {
mode = "cdrom"
}
return mode, nil
}
func rpcIsUpdatePending() (bool, error) {
return otaState.IsUpdatePending(), nil
}
func rpcGetUsbEmulationState() (bool, error) {
return gadget.IsUDCBound()
}
func rpcSetUsbEmulationState(enabled bool) error {
setUSBEmulationDesired(enabled)
if enabled {
return gadget.BindUDC()
} else {
return gadget.UnbindUDC()
}
}
func rpcGetUsbConfig() (usbgadget.Config, error) {
LoadConfig()
return *config.UsbConfig, nil
}
func rpcSetUsbConfig(usbConfig usbgadget.Config) error {
LoadConfig()
config.UsbConfig = &usbConfig
gadget.SetGadgetConfig(config.UsbConfig)
return updateUsbRelatedConfig()
}
func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) {
if config.WakeOnLanDevices == nil {
return []WakeOnLanDevice{}, nil
}
return config.WakeOnLanDevices, nil
}
type SetWakeOnLanDevicesParams struct {
Devices []WakeOnLanDevice `json:"devices"`
}
func rpcSetWakeOnLanDevices(params SetWakeOnLanDevicesParams) error {
config.WakeOnLanDevices = params.Devices
return SaveConfig()
}
// resetConfig resets the config file to defaults. Used internally by OTA updates and native events.
func resetConfig() error {
defaultConfig := getDefaultConfig()
config = &defaultConfig
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to reset config: %w", err)
}
logger.Info().Msg("Configuration reset to default")
return nil
}
// factoryResetPaths lists all user data paths that should be removed during a factory reset.
var factoryResetPaths = []string{
configPath,
configPath + ".bak",
imagesFolder,
tlsStorePath,
sshKeyDir,
serialSettingsPath,
SerialCommandHistoryPath,
failsafeDefaultLastCrashPath,
}
func rpcFactoryReset() error {
logger.Info().Msg("Factory reset initiated, removing all user data")
var errs []error
for _, path := range factoryResetPaths {
if err := os.RemoveAll(path); err != nil {
logger.Warn().Err(err).Str("path", path).Msg("failed to remove path during factory reset")
errs = append(errs, err)
}
}
if len(errs) > 0 {
logger.Warn().Int("errors", len(errs)).Msg("factory reset completed with errors, rebooting anyway")
} else {
logger.Info().Msg("Factory reset complete, rebooting device")
}
// Reboot asynchronously to allow the RPC response to be sent first.
go func() {
time.Sleep(1 * time.Second)
if err := hwReboot(true, nil, 0); err != nil {
logger.Error().Err(err).Msg("failed to reboot after factory reset")
}
}()
return nil
}
type DCPowerState struct {
IsOn bool `json:"isOn"`
Voltage float64 `json:"voltage"`
Current float64 `json:"current"`
Power float64 `json:"power"`
RestoreState int `json:"restoreState"`
}
func rpcGetDCPowerState() (DCPowerState, error) {
return getDCState(), nil
}
func rpcSetDCPowerState(enabled bool) error {
logger.Info().Bool("enabled", enabled).Msg("Setting DC power state")
err := setDCPowerState(enabled)
if err != nil {
return fmt.Errorf("failed to set DC power state: %w", err)
}
return nil
}
func rpcSetDCRestoreState(state int) error {
logger.Info().Int("state", state).Msg("Setting DC restore state")
err := setDCRestoreState(state)
if err != nil {
return fmt.Errorf("failed to set DC restore state: %w", err)
}
return nil
}
func rpcGetActiveExtension() (string, error) {
return config.ActiveExtension, nil
}
func rpcSetActiveExtension(extensionId string) error {
if config.ActiveExtension == extensionId {
return nil
}
switch config.ActiveExtension {
case "atx-power":
_ = unmountATXControl()
case "dc-power":
_ = unmountDCControl()
}
config.ActiveExtension = extensionId
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
switch extensionId {
case "atx-power":
_ = mountATXControl()
case "dc-power":
_ = mountDCControl()
}
// Re-publish MQTT HA Discovery for the new extension
if mqttManager != nil {
mqttManager.republishHADiscovery()
}
return nil
}
func rpcSetATXPowerAction(action string) error {
logger.Debug().Str("action", action).Msg("Executing ATX power action")
switch action {
case "power-short":
logger.Debug().Msg("Simulating short power button press")
return pressATXPowerButton(200 * time.Millisecond)
case "power-long":
logger.Debug().Msg("Simulating long power button press")
return pressATXPowerButton(5 * time.Second)
case "reset":
logger.Debug().Msg("Simulating reset button press")
return pressATXResetButton(200 * time.Millisecond)
default:
return fmt.Errorf("invalid action: %s", action)
}
}
type ATXState struct {
Power bool `json:"power"`
HDD bool `json:"hdd"`
}
func rpcGetATXState() (ATXState, error) {
state := ATXState{
Power: ledPWRState.Load(),
HDD: ledHDDState.Load(),
}
return state, nil
}
func rpcSendCustomCommand(command string) error {
logger.Debug().Str("Command", command).Msg("JSONRPC: Sending custom serial command")
err := sendCustomCommand(command)
if err != nil {
return fmt.Errorf("failed to send custom command in jsonrpc: %w", err)
}
return nil
}
func rpcGetSerialSettings() (SerialSettings, error) {
return getSerialSettings()
}
func rpcSetSerialSettings(settings SerialSettings) error {
return setSerialSettings(settings)
}
const SerialCommandHistoryPath = "/userdata/serialCommandHistory.json"
func rpcGetSerialCommandHistory() ([]string, error) {
items := []string{}
file, err := os.Open(SerialCommandHistoryPath)
if err != nil {
logger.Debug().Msg("SerialCommandHistory file doesn't exist, using default")
return items, nil
}
defer file.Close()
// load and merge the default config with the user config
var loadedItems []string
if err := json.NewDecoder(file).Decode(&loadedItems); err != nil {
logger.Warn().Err(err).Msg("SerialCommandHistory file JSON parsing failed")
return items, nil
}
return loadedItems, nil
}
func rpcSetSerialCommandHistory(commandHistory []string) error {
logger.Trace().Str("path", SerialCommandHistoryPath).Msg("Saving serial command history")
file, err := os.Create(SerialCommandHistoryPath)
if err != nil {
return fmt.Errorf("failed to create SerialCommandHistory file: %w", err)
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(commandHistory); err != nil {
return fmt.Errorf("failed to encode SerialCommandHistory: %w", err)
}
return nil
}
func rpcDeleteSerialCommandHistory() error {
logger.Trace().Str("path", SerialCommandHistoryPath).Msg("Deleting serial command history")
empty := []string{}
file, err := os.Create(SerialCommandHistoryPath)
if err != nil {
return fmt.Errorf("failed to create SerialCommandHistory file: %w", err)
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(empty); err != nil {
return fmt.Errorf("failed to encode SerialCommandHistory: %w", err)
}
return nil
}
func rpcSetTerminalPaused(terminalPaused bool) error {
setTerminalPaused(terminalPaused)
return nil
}
func rpcGetUsbDevices() (usbgadget.Devices, error) {
return *config.UsbDevices, nil
}
func updateUsbRelatedConfig() error {
if err := gadget.UpdateGadgetConfig(); err != nil {
return fmt.Errorf("failed to write gadget config: %w", err)
}
// Reset recovery timer so auto-recovery doesn't interfere during
// the host's USB re-enumeration window after a deliberate config change.
setUSBRecoveryTimer(time.Now())
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcSetUsbDevices(usbDevices usbgadget.Devices) error {
config.UsbDevices = &usbDevices
gadget.SetGadgetDevices(config.UsbDevices)
return updateUsbRelatedConfig()
}
func rpcSetUsbDeviceState(device string, enabled bool) error {
switch device {
case "absoluteMouse":
config.UsbDevices.AbsoluteMouse = enabled
case "relativeMouse":
config.UsbDevices.RelativeMouse = enabled
case "keyboard":
config.UsbDevices.Keyboard = enabled
case "massStorage":
config.UsbDevices.MassStorage = enabled
default:
return fmt.Errorf("invalid device: %s", device)
}
gadget.SetGadgetDevices(config.UsbDevices)
return updateUsbRelatedConfig()
}
func rpcSetCloudUrl(apiUrl string, appUrl string) error {
currentCloudURL := config.CloudURL
config.CloudURL = apiUrl
config.CloudAppURL = appUrl
if currentCloudURL != apiUrl {
disconnectCloud(fmt.Errorf("cloud url changed from %s to %s", currentCloudURL, apiUrl))
}
if publicIPState != nil {
publicIPState.SetCloudflareEndpoint(apiUrl)
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcGetKeyboardLayout() (string, error) {
return config.KeyboardLayout, nil
}
func rpcSetKeyboardLayout(layout string) error {
config.KeyboardLayout = layout
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func getKeyboardMacros() (any, error) {
macros := make([]KeyboardMacro, len(config.KeyboardMacros))
copy(macros, config.KeyboardMacros)
return macros, nil
}
type KeyboardMacrosParams struct {
Macros []any `json:"macros"`
}
func setKeyboardMacros(params KeyboardMacrosParams) (any, error) {
if params.Macros == nil {
return nil, fmt.Errorf("missing or invalid macros parameter")
}
newMacros := make([]KeyboardMacro, 0, len(params.Macros))
for i, item := range params.Macros {
macroMap, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid macro at index %d", i)
}
id, _ := macroMap["id"].(string)
if id == "" {
id = fmt.Sprintf("macro-%d", time.Now().UnixNano())
}
name, _ := macroMap["name"].(string)
sortOrder := i + 1
if sortOrderFloat, ok := macroMap["sortOrder"].(float64); ok {
sortOrder = int(sortOrderFloat)
}
steps := []KeyboardMacroStep{}
if stepsArray, ok := macroMap["steps"].([]any); ok {
for _, stepItem := range stepsArray {
stepMap, ok := stepItem.(map[string]any)
if !ok {
continue
}
step := KeyboardMacroStep{}
if keysArray, ok := stepMap["keys"].([]any); ok {
for _, k := range keysArray {
if keyStr, ok := k.(string); ok {
step.Keys = append(step.Keys, keyStr)
}
}
}
if modsArray, ok := stepMap["modifiers"].([]any); ok {
for _, m := range modsArray {
if modStr, ok := m.(string); ok {
step.Modifiers = append(step.Modifiers, modStr)
}
}
}
if delay, ok := stepMap["delay"].(float64); ok {
step.Delay = int(delay)
}
steps = append(steps, step)
}
}
macro := KeyboardMacro{
ID: id,
Name: name,
Steps: steps,
SortOrder: sortOrder,
}
if err := macro.Validate(); err != nil {
return nil, fmt.Errorf("invalid macro at index %d: %w", i, err)
}
newMacros = append(newMacros, macro)
}
config.KeyboardMacros = newMacros
if err := SaveConfig(); err != nil {
return nil, err
}
return nil, nil
}
func rpcGetLocalLoopbackOnly() (bool, error) {
return config.LocalLoopbackOnly, nil
}
func rpcSetLocalLoopbackOnly(enabled bool) error {
// Check if the setting is actually changing
if config.LocalLoopbackOnly == enabled {
return nil
}
// Update the setting
config.LocalLoopbackOnly = enabled
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
var (
keyboardMacroCancel context.CancelFunc
keyboardMacroLock sync.Mutex
)
// cancelKeyboardMacro cancels any ongoing keyboard macro execution
func cancelKeyboardMacro() {
keyboardMacroLock.Lock()
defer keyboardMacroLock.Unlock()
if keyboardMacroCancel != nil {
keyboardMacroCancel()
logger.Info().Msg("canceled keyboard macro")
keyboardMacroCancel = nil
}
}
func setKeyboardMacroCancel(cancel context.CancelFunc) {
keyboardMacroLock.Lock()
defer keyboardMacroLock.Unlock()
keyboardMacroCancel = cancel
}
func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error {
cancelKeyboardMacro()
ctx, cancel := context.WithCancel(context.Background())
setKeyboardMacroCancel(cancel)
s := hidrpc.KeyboardMacroState{
State: true,
IsPaste: true,
}
if currentSession != nil {
currentSession.reportHidRPCKeyboardMacroState(s)
}
err := rpcDoExecuteKeyboardMacro(ctx, macro)
setKeyboardMacroCancel(nil)
s.State = false
if currentSession != nil {
currentSession.reportHidRPCKeyboardMacroState(s)
}
return err
}
func rpcCancelKeyboardMacro() {
cancelKeyboardMacro()
}
var keyboardClearStateKeys = make([]byte, hidrpc.HidKeyBufferSize)
func isClearKeyStep(step hidrpc.KeyboardMacroStep) bool {
return step.Modifier == 0 && bytes.Equal(step.Keys, keyboardClearStateKeys)
}
func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacroStep) error {
logger.Debug().Interface("macro", macro).Msg("Executing keyboard macro")
for i, step := range macro {
delay := time.Duration(step.Delay) * time.Millisecond
err := rpcKeyboardReport(step.Modifier, step.Keys)
if err != nil {
logger.Warn().Err(err).Msg("failed to execute keyboard macro")
return err
}
// notify the device that the keyboard state is being cleared
if isClearKeyStep(step) {
gadget.UpdateKeysDown(0, keyboardClearStateKeys)
}
// Use context-aware sleep that can be cancelled
select {
case <-time.After(delay):
// Sleep completed normally
case <-ctx.Done():
// make sure keyboard state is reset
err := rpcKeyboardReport(0, keyboardClearStateKeys)
if err != nil {
logger.Warn().Err(err).Msg("failed to reset keyboard state")
}
logger.Debug().Int("step", i).Msg("Keyboard macro cancelled during sleep")
return ctx.Err()
}
}
return nil
}
var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing},
"reboot": {Func: rpcReboot, Params: []string{"force"}},
"getDeviceID": {Func: rpcGetDeviceID},
"deregisterDevice": {Func: rpcDeregisterDevice},
"getCloudState": {Func: rpcGetCloudState},
"getNetworkState": {Func: rpcGetNetworkState},
"getNetworkSettings": {Func: rpcGetNetworkSettings},
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
"renewDHCPLease": {Func: rpcRenewDHCPLease},
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
"getKeyDownState": {Func: rpcGetKeysDownState},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY", "wheelX"}},
"getVideoState": {Func: rpcGetVideoState},
"getUSBState": {Func: rpcGetUSBState},
"unmountImage": {Func: rpcUnmountImage},
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
"getJigglerState": {Func: rpcGetJigglerState},
"setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}},
"getJigglerConfig": {Func: rpcGetJigglerConfig},
"getTimezones": {Func: rpcGetTimezones},
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
"getEDID": {Func: rpcGetEDID},
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
"getVideoLogStatus": {Func: rpcGetVideoLogStatus},
"getVideoSleepMode": {Func: rpcGetVideoSleepMode},
"setVideoSleepMode": {Func: rpcSetVideoSleepMode, Params: []string{"duration"}},
"getDevChannelState": {Func: rpcGetDevChannelState},
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getLocalVersion": {Func: rpcGetLocalVersion},
"getUpdateStatus": {Func: rpcGetUpdateStatus},
"checkUpdateComponents": {Func: rpcCheckUpdateComponents, Params: []string{"params", "includePreRelease"}},
"getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel},
"tryUpdate": {Func: rpcTryUpdate},
"tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"params", "includePreRelease", "resetConfig"}},
"getDevModeState": {Func: rpcGetDevModeState},
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
"getSSHKeyState": {Func: rpcGetSSHKeyState},
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
"getTLSState": {Func: rpcGetTLSState},
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
"getMassStorageMode": {Func: rpcGetMassStorageMode},
"isUpdatePending": {Func: rpcIsUpdatePending},
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
"getUsbConfig": {Func: rpcGetUsbConfig},
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
"getStorageSpace": {Func: rpcGetStorageSpace},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
"listStorageFiles": {Func: rpcListStorageFiles},
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"factoryReset": {Func: rpcFactoryReset},
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
"getDisplayRotation": {Func: rpcGetDisplayRotation},
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
"getBacklightSettings": {Func: rpcGetBacklightSettings},
"getDCPowerState": {Func: rpcGetDCPowerState},
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
"setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}},
"getActiveExtension": {Func: rpcGetActiveExtension},
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
"getATXState": {Func: rpcGetATXState},
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
"getSerialSettings": {Func: rpcGetSerialSettings},
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
"sendCustomCommand": {Func: rpcSendCustomCommand, Params: []string{"command"}},
"getSerialCommandHistory": {Func: rpcGetSerialCommandHistory},
"setSerialCommandHistory": {Func: rpcSetSerialCommandHistory, Params: []string{"commandHistory"}},
"deleteSerialCommandHistory": {Func: rpcDeleteSerialCommandHistory},
"setTerminalPaused": {Func: rpcSetTerminalPaused, Params: []string{"terminalPaused"}},
"getUsbDevices": {Func: rpcGetUsbDevices},
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
"getKeyboardMacros": {Func: getKeyboardMacros},
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
"getPublicIPAddresses": {Func: rpcGetPublicIPAddresses, Params: []string{"refresh"}},
"checkPublicIPAddresses": {Func: rpcCheckPublicIPAddresses},
"getTailscaleStatus": {Func: rpcGetTailscaleStatus},
"getTailscaleControlURL": {Func: rpcGetTailscaleControlURL},
"setTailscaleControlURL": {Func: rpcSetTailscaleControlURL, Params: []string{"controlURL"}},
"getMqttSettings": {Func: rpcGetMqttSettings},
"setMqttSettings": {Func: rpcSetMqttSettings, Params: []string{"settings"}},
"getMqttStatus": {Func: rpcGetMqttStatus},
"testMqttConnection": {Func: rpcTestMqttConnection, Params: []string{"settings"}},
}