-
+# jetkvm-next
-### KVM
+> jetkvm-next is not affiliated with, nor supported by, JetKVM or BuildJet.
-[Discord](https://jetkvm.com/discord) | [Website](https://jetkvm.com) | [Issues](https://github.com/jetkvm/cloud-api/issues) | [Docs](https://jetkvm.com/docs)
+jetkvm-next is a fork of the JetKVM application with various in-progress features merged in from commnunity
+pull requests.
-[](https://twitter.com/jetkvm)
+This branch isn't meant to be pulled into the upstream, and will almost certainly contain some bugs, it's a
+bleeding-edge build of the software that community members can use to try out new features, or for developers to check
+their upcoming features don't clash with other in-progress PRs.
-
+Main repo: https://github.com/jetkvm/kvm
-JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) solution designed for efficient remote management of computers, servers, and workstations. Whether you're dealing with boot failures, installing a new operating system, adjusting BIOS settings, or simply taking control of a machine from afar, JetKVM provides the tools to get it done effectively.
+## Current Additional Features
+The below [in-development features](https://github.com/jetkvm/kvm) are currently included in `jetkvm-next`.
+The commits from the developer's working tree and cherry picked into this branch, to check the "version" of the feature,
+compare the commit hash on this branch, to the current hash of the commit(s) in the pull request.
-## Features
+- tutman - [Plugin System](https://github.com/jetkvm/kvm/pull/10)
+- SuperQ - [Prometheus Metrics](https://github.com/jetkvm/kvm/pull/6)
+- Nevexo - [Force-release IPv4 addresses on Link Down](https://github.com/jetkvm/kvm/pull/16)
+- Nevexo - [Display backlight brightness control](https://github.com/jetkvm/kvm/pull/17)
+- Nevexo - [CTRL+ALT+DEL Button on Action Bar](https://github.com/jetkvm/kvm/pull/18)
+- tutman - [Clean-up jetkvm_native when app exits](https://github.com/jetkvm/kvm/pull/19)
+- Nevexo - [Only start WebSocket client when necessary](https://github.com/jetkvm/kvm/pull/27)
+- Nevexo - [Restore EDID on Reboot](https://github.com/jetkvm/kvm/pull/34)
+- tutman - [Remove Rounded Corners](https://github.com/jetkvm/kvm/pull/86)
+- antonym - [Update ISO Versions](https://github.com/jetkvm/kvm/pull/78)
+- tutman - [Fix fullscreen video absolute position](https://github.com/jetkvm/kvm/pull/85)
+- jackislanding - [Allow configuring USB IDs](https://github.com/jetkvm/kvm/pulls/90)
+- williamjohnstone - [Multiple Keyboard Layouts](https://github.com/jetkvm/kvm/pull/116)
+- andnic - [USB HID Fix](https://github.com/jetkvm/kvm/pull/113)
+- Nevexo - Add Reboot Button (No PR for this as it's not final)
-- **Ultra-low Latency** - 1080p@60FPS video with 30-60ms latency using H.264 encoding. Smooth mouse and keyboard interaction for responsive remote control.
-- **Free & Optional Remote Access** - Remote management via JetKVM Cloud using WebRTC.
-- **Open-source software** - Written in Golang on Linux. Easily customizable through SSH access to the JetKVM device.
+If you're looking to try these features out in jetkvm-next, you should go to the PR and read the authors documentation.
-## Contributing
+## next-multisession
+As requested by a few in the [JetKVM Discord](https://jetkvm.com/discord), this tree also includes a branch that enables
+support for multiple sessions connecting to the JetKVM.
-We welcome contributions from the community! Whether it's improving the firmware, adding new features, or enhancing documentation, your input is valuable. We also have some rules and taboos here, so please read this page and our [Code of Conduct](/CODE_OF_CONDUCT.md) carefully.
+It's a bit of a bodge implementation, but shows the multiple sessions can be handled by the JetKVM.
-## I need help
+Every release of jetkvm-next includes jetkvm-next-multisession in a pre-release, the jetkvm-next-multisession branch is based
+off the main jetkvm-next branch, and applies changes to the session handling code.
-The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://discord.gg/8MaAhua7NW).
+next-muiltisession does not include any concept of control authority/mutex, so all users connected will have full control
+over the target machine, and you'll be fighting for the cursor with the other user.
-## I want to report an issue
+## Installation
+> You should read this section multiple times before even plugging in the JetKVM device.
+> If these instructions don't immediately make sense, then it's probably best to avoid installing
+> jetkvm-next, it's incredibly bleeding-edge, and could explode in a million different ways.
-If you've found an issue and want to report it, please check our [Issues](https://github.com/jetkvm/kvm/issues) page. Make sure the description contains information about the firmware version you're using, your platform, and a clear explanation of the steps to reproduce the issue.
+**DISCLAIMER:** This is very much beta, canary, unstable, software there could be bugs that cause
+damage to your JetKVM hardware, such as wearing out the eMMC, breaking the LCD or overheating.
-# Development
+**On Windows?:** Install WSL now, it makes life much easier.
-JetKVM is written in Go & TypeScript. with some bits and pieces written in C. An intermediate level of Go & TypeScript knowledge is recommended for comfortable programming.
+### Prepare the KVM
+Boot up your KVM, login, and enable SSH access with your SSH public key.
-The project contains two main parts, the backend software that runs on the KVM device and the frontend software that is served by the KVM device, and also the cloud.
+Test you can login to the KVM with your SSH key, remember the username is `root`.
-For most of local device development, all you need is to use the `./dev_deploy.sh` script. It will build the frontend and backend and deploy them to the local KVM device. Run `./dev_deploy.sh --help` for more information.
+### Build your own binary
+While I provide pre-compiled binaries with every [release](https://github.com/nevexo/jetkvm-kvm/releases), you can (and should) build
+the binary yourself, this allows you to analyse the code running on your device, and be familiar with the innerworkings of the JetKVM
+software stack, before you have to start debugging it.
-## Backend
+#### You will need:
+- A JavaScript runtime, such as Node.JS ([Bun](https://bun.sh) works fine, but you'll need to adjust the Makefile)
+- Git
+- Go (and the various compilers for ARMv7)
+- make
-The backend is written in Go and is responsible for the KVM device management, the cloud API and the cloud web.
+#### Get the code
+Make yourself a directory to keep jetkvm-next in, and clone the repo:
-## Frontend
+`git clone https://github.com/nevexo/jetkvm-kvm.git`
-The frontend is written in React and TypeScript and is served by the KVM device. It has three build targets: `device`, `development` and `production`. Development is used for development of the cloud version on your local machine, device is used for building the frontend for the KVM device and production is used for building the frontend for the cloud.
+(If you're updating, just do `git pull` in the jetkvm-kvm directory.)
+
+As the next branch is the main development branch of jetkvm-next, it may not build as-is, so you should check-out
+one of the tags for the version you want to use. At the time of writing, that's next-7, but if I forget to update
+the README (I will) you should check [the releases page](https://github.com/nevexo/jetkvm-kvm/releases) for the latest tags.
+
+`git checkout next-7`
+
+If you want multisession, then stick -multisession on the end of the checkout command, but note I usually release multisession
+a little bit later than the default.
+
+Your code will now be in line with the code in the binary released.
+
+#### Build the code
+This will automatically build both the frontend and the jetkvm-next binary. If you don't have the proper ARM compilers
+installed for Go, you'll see some errors, simply Google the package that Go says is missing, and the name of your OS, and you'll
+be able to find it. (If you're on WSL, search for the distro you're using, not Windows)
+
+`make build_next`
+
+#### Deploy the binary
+**NOTE:** There's a bug in next_deploy.sh for all versions next-7 and older, so if you're building one of those, you'll need to
+run these commands first:
+
+```
+mkdir -p bin/next
+touch bin/jetkvm_app
+```
+(the script checks if the normal jetkvm_app binary exists, even though it only needs the one in bin/next, oops!)
+
+Run the deployment script:
+```
+./next_deploy.sh -r [address of kvm]
+```
+
+After a moment, you should see `Deployment complete!` - skip to the bottom to see how to launch it.
+
+### Use the provided binary
+Again, I highly recommend you get familiar with the innerworkings of the JetKVM stack and build your own binaries.
+But, if you can't be bothered with the above:
+
+#### Get the binary
+Simply go to the releases page, and download the latest available image, you can choose the multisession version at this stage, if you wish.
+
+Pop the binary somewhere that you can get to with your terminal (on WSL, that's probably /mnt/c/Users/[yourname]/Downloads)
+
+#### Deploy the binary
+**NOTE:** The buildroot image on the JetKVM doesn't have support for scp, so this is where it gets interesting.
+
+Use `cat` to send the contents of the jetkvm_app_next binary over to your KVM.
+
+`cat jetkvm_app_next | ssh "root@[IP of JetKVM]" "cat > /userdata/jetkvm/bin/jetkvm_app_next"`
+
+That's it :)
+
+## Run jetkvm-next
+**NOTE:** You need to be somewhat quick at doing this as the kernel watchdog timer will reboot the jetkvm
+if the jetkvm_app binary hasn't been running for a while. You can turn that off by running `echo 'V' > /dev/watchdog`
+
+To run jetkvm-next now, run:
+```
+cd /userdata/jetkvm/bin
+killall jetkvm_app
+killall jetkvm_native
+./jetkvm_app_next
+```
+
+The app will launch, and you can try out the new features! When you reboot the device, it'll return to jetkvm_app.
+
+### Use jetkvm-next by default
+You can rename the jetkvm_app binaries to make the KVM start next by default.
+
+```
+cd /userdata/jetkvm/bin
+killall jetkvm_app
+killall jetkvm_native
+mv jetkvm_app jetkvm_app_old
+mv jetkvm_app_next jetkvm_app
+reboot
+```
+
+Your JetKVM is now running jetkvm-next!
+
+### Going back to stable
+If you followed the above instructions properly, switching back to stable is easy.
+
+```
+cd /userdata/jetkvm/bin
+killall jetkvm_app
+mv jetkvm_app jetkvm_app_next
+mv jetkvm_app_old jetkvm_app
+```
+
+If you lost jetkvm_app_old, then [factory reset](https://jetkvm.com/docs/advanced-usage/factory-reset).
\ No newline at end of file
diff --git a/cloud.go b/cloud.go
index 5088ec73..3520e2f8 100644
--- a/cloud.go
+++ b/cloud.go
@@ -7,13 +7,14 @@ import (
"fmt"
"net/http"
"net/url"
- "github.com/coder/websocket/wsjson"
"time"
+ "github.com/coder/websocket/wsjson"
+
"github.com/coreos/go-oidc/v3/oidc"
- "github.com/gin-gonic/gin"
"github.com/coder/websocket"
+ "github.com/gin-gonic/gin"
)
type CloudRegisterRequest struct {
@@ -192,7 +193,11 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
return fmt.Errorf("google identity mismatch")
}
- session, err := newSession()
+ session, err := newSession(SessionConfig{
+ ICEServers: req.ICEServers,
+ LocalIP: req.IP,
+ IsCloud: true,
+ })
if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
return err
diff --git a/config.go b/config.go
index 1636434a..82442ae3 100644
--- a/config.go
+++ b/config.go
@@ -11,17 +11,32 @@ type WakeOnLanDevice struct {
MacAddress string `json:"macAddress"`
}
+type UsbConfig struct {
+ VendorId string `json:"vendor_id"`
+ ProductId string `json:"product_id"`
+ SerialNumber string `json:"serial_number"`
+ Manufacturer string `json:"manufacturer"`
+ Product string `json:"product"`
+}
+
type Config struct {
- CloudURL string `json:"cloud_url"`
- CloudToken string `json:"cloud_token"`
- GoogleIdentity string `json:"google_identity"`
- JigglerEnabled bool `json:"jiggler_enabled"`
- AutoUpdateEnabled bool `json:"auto_update_enabled"`
- IncludePreRelease bool `json:"include_pre_release"`
- HashedPassword string `json:"hashed_password"`
- LocalAuthToken string `json:"local_auth_token"`
- LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
- WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
+ CloudURL string `json:"cloud_url"`
+ CloudToken string `json:"cloud_token"`
+ GoogleIdentity string `json:"google_identity"`
+ JigglerEnabled bool `json:"jiggler_enabled"`
+ AutoUpdateEnabled bool `json:"auto_update_enabled"`
+ KeyboardLayout string `json:"keyboard_layout"`
+ IncludePreRelease bool `json:"include_pre_release"`
+ HashedPassword string `json:"hashed_password"`
+ LocalAuthToken string `json:"local_auth_token"`
+ LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
+ WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
+ DisplayMaxBrightness int `json:"display_max_brightness"`
+ DisplayDimAfterSec int `json:"display_dim_after_sec"`
+ DisplayOffAfterSec int `json:"display_off_after_sec"`
+ EdidString string `json:"hdmi_edid_string"`
+ UsbConfig UsbConfig `json:"usb_config"`
+ VirtualMediaEnabled bool `json:"virtual_media_enabled"`
}
const configPath = "/userdata/kvm_config.json"
diff --git a/display.go b/display.go
index f312eb66..416401b0 100644
--- a/display.go
+++ b/display.go
@@ -1,12 +1,26 @@
package kvm
import (
+ "errors"
"fmt"
"log"
+ "os"
+ "strconv"
"time"
)
var currentScreen = "ui_Boot_Screen"
+var backlightState = 0 // 0 - NORMAL, 1 - DIMMED, 2 - OFF
+
+var (
+ dimTicker *time.Ticker
+ offTicker *time.Ticker
+)
+
+const (
+ touchscreenDevice string = "/dev/input/event1"
+ backlightControlClass string = "/sys/class/backlight/backlight/brightness"
+)
func switchToScreen(screen string) {
_, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen})
@@ -65,6 +79,7 @@ func requestDisplayUpdate() {
return
}
go func() {
+ wakeDisplay(false)
fmt.Println("display updating........................")
//TODO: only run once regardless how many pending updates
updateDisplay()
@@ -83,6 +98,156 @@ func updateStaticContents() {
updateLabelIfChanged("ui_Status_Content_Device_Id_Content_Label", GetDeviceID())
}
+// setDisplayBrightness sets /sys/class/backlight/backlight/brightness to alter
+// the backlight brightness of the JetKVM hardware's display.
+func setDisplayBrightness(brightness int) error {
+ // NOTE: The actual maximum value for this is 255, but out-of-the-box, the value is set to 64.
+ // The maximum set here is set to 100 to reduce the risk of drawing too much power (and besides, 255 is very bright!).
+ if brightness > 100 || brightness < 0 {
+ return errors.New("brightness value out of bounds, must be between 0 and 100")
+ }
+
+ // Check the display backlight class is available
+ if _, err := os.Stat(backlightControlClass); errors.Is(err, os.ErrNotExist) {
+ return errors.New("brightness value cannot be set, possibly not running on JetKVM hardware")
+ }
+
+ // Set the value
+ bs := []byte(strconv.Itoa(brightness))
+ err := os.WriteFile(backlightControlClass, bs, 0644)
+ if err != nil {
+ return err
+ }
+
+ fmt.Printf("display: set brightness to %v\n", brightness)
+ return nil
+}
+
+// tick_displayDim() is called when when dim ticker expires, it simply reduces the brightness
+// of the display by half of the max brightness.
+func tick_displayDim() {
+ err := setDisplayBrightness(config.DisplayMaxBrightness / 2)
+ if err != nil {
+ fmt.Printf("display: failed to dim display: %s\n", err)
+ }
+
+ dimTicker.Stop()
+
+ backlightState = 1
+}
+
+// tick_displayOff() is called when the off ticker expires, it turns off the display
+// by setting the brightness to zero.
+func tick_displayOff() {
+ err := setDisplayBrightness(0)
+ if err != nil {
+ fmt.Printf("display: failed to turn off display: %s\n", err)
+ }
+
+ offTicker.Stop()
+
+ backlightState = 2
+}
+
+// wakeDisplay sets the display brightness back to config.DisplayMaxBrightness and stores the time the display
+// last woke, ready for displayTimeoutTick to put the display back in the dim/off states.
+// Set force to true to skip the backlight state check, this should be done if altering the tickers.
+func wakeDisplay(force bool) {
+ if backlightState == 0 && !force {
+ return
+ }
+
+ // Don't try to wake up if the display is turned off.
+ if config.DisplayMaxBrightness == 0 {
+ return
+ }
+
+ err := setDisplayBrightness(config.DisplayMaxBrightness)
+ if err != nil {
+ fmt.Printf("display wake failed, %s\n", err)
+ }
+
+ if config.DisplayDimAfterSec != 0 {
+ dimTicker.Reset(time.Duration(config.DisplayDimAfterSec) * time.Second)
+ }
+
+ if config.DisplayOffAfterSec != 0 {
+ offTicker.Reset(time.Duration(config.DisplayOffAfterSec) * time.Second)
+ }
+ backlightState = 0
+}
+
+// watchTsEvents monitors the touchscreen for events and simply calls wakeDisplay() to ensure the
+// touchscreen interface still works even with LCD dimming/off.
+// TODO: This is quite a hack, really we should be getting an event from jetkvm_native, or the whole display backlight
+// control should be hoisted up to jetkvm_native.
+func watchTsEvents() {
+ ts, err := os.OpenFile(touchscreenDevice, os.O_RDONLY, 0666)
+ if err != nil {
+ fmt.Printf("display: failed to open touchscreen device: %s\n", err)
+ return
+ }
+
+ defer ts.Close()
+
+ // This buffer is set to 24 bytes as that's the normal size of events on /dev/input
+ // Reference: https://www.kernel.org/doc/Documentation/input/input.txt
+ // This could potentially be set higher, to require multiple events to wake the display.
+ buf := make([]byte, 24)
+ for {
+ _, err := ts.Read(buf)
+ if err != nil {
+ fmt.Printf("display: failed to read from touchscreen device: %s\n", err)
+ return
+ }
+
+ wakeDisplay(false)
+ }
+}
+
+// startBacklightTickers starts the two tickers for dimming and switching off the display
+// if they're not already set. This is done separately to the init routine as the "never dim"
+// option has the value set to zero, but time.NewTicker only accept positive values.
+func startBacklightTickers() {
+ LoadConfig()
+ // Don't start the tickers if the display is switched off.
+ // Set the display to off if that's the case.
+ if config.DisplayMaxBrightness == 0 {
+ setDisplayBrightness(0)
+ return
+ }
+
+ if dimTicker == nil && config.DisplayDimAfterSec != 0 {
+ fmt.Printf("display: dim_ticker has started\n")
+ dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second)
+ defer dimTicker.Stop()
+
+ go func() {
+ for {
+ select {
+ case <-dimTicker.C:
+ tick_displayDim()
+ }
+ }
+ }()
+ }
+
+ if offTicker == nil && config.DisplayOffAfterSec != 0 {
+ fmt.Printf("display: off_ticker has started\n")
+ offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second)
+ defer offTicker.Stop()
+
+ go func() {
+ for {
+ select {
+ case <-offTicker.C:
+ tick_displayOff()
+ }
+ }
+ }()
+ }
+}
+
func init() {
go func() {
waitCtrlClientConnected()
@@ -91,6 +256,10 @@ func init() {
updateStaticContents()
displayInited = true
fmt.Println("display inited")
+ startBacklightTickers()
+ wakeDisplay(true)
requestDisplayUpdate()
}()
+
+ go watchTsEvents()
}
diff --git a/go.mod b/go.mod
index 5ddcfb68..15a6c7d0 100644
--- a/go.mod
+++ b/go.mod
@@ -19,17 +19,21 @@ require (
github.com/pion/mdns/v2 v2.0.7
github.com/pion/webrtc/v4 v4.0.0
github.com/pojntfx/go-nbd v0.3.2
+ github.com/prometheus/client_golang v1.20.5
+ github.com/prometheus/common v0.61.0
github.com/psanford/httpreadat v0.1.0
github.com/vishvananda/netlink v1.3.0
- golang.org/x/crypto v0.28.0
- golang.org/x/net v0.30.0
+ golang.org/x/crypto v0.30.0
+ golang.org/x/net v0.32.0
)
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
require (
+ github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
@@ -40,12 +44,13 @@ require (
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
- github.com/kr/pretty v0.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pilebones/go-udev v0.9.0 // indirect
github.com/pion/datachannel v1.5.9 // indirect
@@ -61,16 +66,16 @@ require (
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
- github.com/rogpeppe/go-internal v1.8.0 // indirect
+ github.com/prometheus/client_model v0.6.1 // indirect
+ github.com/prometheus/procfs v0.15.1 // 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
golang.org/x/arch v0.8.0 // indirect
- golang.org/x/oauth2 v0.21.0 // indirect
- golang.org/x/sys v0.26.0 // indirect
- golang.org/x/text v0.19.0 // indirect
- google.golang.org/protobuf v1.34.0 // indirect
- gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+ golang.org/x/oauth2 v0.24.0 // indirect
+ golang.org/x/sys v0.28.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
+ google.golang.org/protobuf v1.35.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index be219176..51a7336b 100644
--- a/go.sum
+++ b/go.sum
@@ -2,10 +2,14 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/beevik/ntp v1.3.1 h1:Y/srlT8L1yQr58kyPWFPZIxRL8ttx2SRIpVYJqZIlAM=
github.com/beevik/ntp v1.3.1/go.mod h1:fT6PylBq86Tsq23ZMEe47b7QQrZfYBFPnpzt0a9kJxw=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b h1:dSbDgy72Y1sjLPWLv7vs0fMFuhMBMViiT9PJZiZWZNs=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b/go.mod h1:SehHnbi2e8NiSAKby42Itm8SIoS7b+wAprsfPH3qgYk=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
@@ -16,7 +20,6 @@ github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NA
github.com/coder/websocket v1.8.12/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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -40,8 +43,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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=
@@ -51,20 +54,22 @@ github.com/hanwen/go-fuse/v2 v2.5.1 h1:OQBE8zVemSocRxA4OaFJbjJ5hlpCmIWbGr7r0M4uo
github.com/hanwen/go-fuse/v2 v2.5.1/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs=
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.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
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.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
-github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -76,6 +81,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965 h1:bZGtUfkOl0dqvem8ltx9KCYied0gSlRuDhaZDxgppN4=
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965/go.mod h1:6cAIK2c4O3/yETSrRjmNwsBL3yE4Vcu9M9p/Qwx5+gM=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
@@ -114,14 +121,20 @@ github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8=
github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto=
-github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
+github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
+github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
+github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
+github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ=
+github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s=
+github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
+github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
-github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
-github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
-github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
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=
@@ -132,8 +145,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+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=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
@@ -147,29 +161,27 @@ github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguH
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
-golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
-golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
-golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
-golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
-golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
-golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY=
+golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
+golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
+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-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/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.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
-golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
-golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
-google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
-google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
+google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/jsonrpc/router.go b/internal/jsonrpc/router.go
new file mode 100644
index 00000000..0534432c
--- /dev/null
+++ b/internal/jsonrpc/router.go
@@ -0,0 +1,300 @@
+package jsonrpc
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "reflect"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+type JSONRPCRouter struct {
+ writer io.Writer
+
+ handlers map[string]*RPCHandler
+ nextId atomic.Int64
+
+ responseChannelsMutex sync.Mutex
+ responseChannels map[int64]chan JSONRPCResponse
+}
+
+func NewJSONRPCRouter(writer io.Writer, handlers map[string]*RPCHandler) *JSONRPCRouter {
+ return &JSONRPCRouter{
+ writer: writer,
+ handlers: handlers,
+
+ responseChannels: make(map[int64]chan JSONRPCResponse),
+ }
+}
+
+func (s *JSONRPCRouter) Request(method string, params map[string]interface{}, result interface{}) *JSONRPCResponseError {
+ id := s.nextId.Add(1)
+ request := JSONRPCRequest{
+ JSONRPC: "2.0",
+ Method: method,
+ Params: params,
+ ID: id,
+ }
+ requestBytes, err := json.Marshal(request)
+ if err != nil {
+ return &JSONRPCResponseError{
+ Code: -32700,
+ Message: "Parse error",
+ Data: err,
+ }
+ }
+
+ // log.Printf("Sending RPC request: Method=%s, Params=%v, ID=%d", method, params, id)
+
+ responseChan := make(chan JSONRPCResponse, 1)
+ s.responseChannelsMutex.Lock()
+ s.responseChannels[id] = responseChan
+ s.responseChannelsMutex.Unlock()
+ defer func() {
+ s.responseChannelsMutex.Lock()
+ delete(s.responseChannels, id)
+ s.responseChannelsMutex.Unlock()
+ }()
+
+ _, err = s.writer.Write(requestBytes)
+ if err != nil {
+ return &JSONRPCResponseError{
+ Code: -32603,
+ Message: "Internal error",
+ Data: err,
+ }
+ }
+
+ timeout := time.After(5 * time.Second)
+ select {
+ case response := <-responseChan:
+ if response.Error != nil {
+ return response.Error
+ }
+
+ rawResult, err := json.Marshal(response.Result)
+ if err != nil {
+ return &JSONRPCResponseError{
+ Code: -32603,
+ Message: "Internal error",
+ Data: err,
+ }
+ }
+
+ if err := json.Unmarshal(rawResult, result); err != nil {
+ return &JSONRPCResponseError{
+ Code: -32603,
+ Message: "Internal error",
+ Data: err,
+ }
+ }
+
+ return nil
+ case <-timeout:
+ return &JSONRPCResponseError{
+ Code: -32603,
+ Message: "Internal error",
+ Data: "timeout waiting for response",
+ }
+ }
+}
+
+type JSONRPCMessage struct {
+ Method *string `json:"method,omitempty"`
+ ID *int64 `json:"id,omitempty"`
+}
+
+func (s *JSONRPCRouter) HandleMessage(data []byte) error {
+ // Data will either be a JSONRPCRequest or JSONRPCResponse object
+ // We need to determine which one it is
+ var raw JSONRPCMessage
+ err := json.Unmarshal(data, &raw)
+ if err != nil {
+ errorResponse := JSONRPCResponse{
+ JSONRPC: "2.0",
+ Error: &JSONRPCResponseError{
+ Code: -32700,
+ Message: "Parse error",
+ },
+ ID: 0,
+ }
+ return s.writeResponse(errorResponse)
+ }
+
+ if raw.Method == nil && raw.ID != nil {
+ var resp JSONRPCResponse
+ if err := json.Unmarshal(data, &resp); err != nil {
+ fmt.Println("error unmarshalling response", err)
+ return err
+ }
+
+ s.responseChannelsMutex.Lock()
+ responseChan, ok := s.responseChannels[*raw.ID]
+ s.responseChannelsMutex.Unlock()
+ if ok {
+ responseChan <- resp
+ } else {
+ log.Println("No response channel found for ID", resp.ID)
+ }
+ return nil
+ }
+
+ var request JSONRPCRequest
+ err = json.Unmarshal(data, &request)
+ if err != nil {
+ errorResponse := JSONRPCResponse{
+ JSONRPC: "2.0",
+ Error: &JSONRPCResponseError{
+ Code: -32700,
+ Message: "Parse error",
+ },
+ ID: 0,
+ }
+ return s.writeResponse(errorResponse)
+ }
+
+ //log.Printf("Received RPC request: Method=%s, Params=%v, ID=%d", request.Method, request.Params, request.ID)
+ handler, ok := s.handlers[request.Method]
+ if !ok {
+ errorResponse := JSONRPCResponse{
+ JSONRPC: "2.0",
+ Error: &JSONRPCResponseError{
+ Code: -32601,
+ Message: "Method not found",
+ },
+ ID: request.ID,
+ }
+ return s.writeResponse(errorResponse)
+ }
+
+ result, err := callRPCHandler(handler, request.Params)
+ if err != nil {
+ errorResponse := JSONRPCResponse{
+ JSONRPC: "2.0",
+ Error: &JSONRPCResponseError{
+ Code: -32603,
+ Message: "Internal error",
+ Data: err.Error(),
+ },
+ ID: request.ID,
+ }
+ return s.writeResponse(errorResponse)
+ }
+
+ response := JSONRPCResponse{
+ JSONRPC: "2.0",
+ Result: result,
+ ID: request.ID,
+ }
+ return s.writeResponse(response)
+}
+
+func (s *JSONRPCRouter) writeResponse(response JSONRPCResponse) error {
+ responseBytes, err := json.Marshal(response)
+ if err != nil {
+ return err
+ }
+ _, err = s.writer.Write(responseBytes)
+ return err
+}
+
+func callRPCHandler(handler *RPCHandler, params map[string]interface{}) (interface{}, 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()
+ args := make([]reflect.Value, numParams)
+ // Get the parameter names from the RPCHandler
+ paramNames := handler.Params
+
+ if len(paramNames) != numParams {
+ return nil, errors.New("mismatch between handler parameters and defined parameter names")
+ }
+
+ for i := 0; i < numParams; i++ {
+ paramType := handlerType.In(i)
+ paramName := paramNames[i]
+ paramValue, ok := params[paramName]
+ if !ok {
+ return nil, errors.New("missing parameter: " + paramName)
+ }
+
+ 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", intValue)
+ }
+ 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", err)
+ }
+
+ newStruct := reflect.New(paramType).Interface()
+ if err := json.Unmarshal(jsonData, newStruct); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v", err)
+ }
+ args[i] = reflect.ValueOf(newStruct).Elem()
+ } else {
+ return nil, fmt.Errorf("invalid parameter type for: %s", paramName)
+ }
+ } else {
+ args[i] = convertedValue.Convert(paramType)
+ }
+ }
+
+ results := handlerValue.Call(args)
+
+ if len(results) == 0 {
+ return nil, nil
+ }
+
+ if len(results) == 1 {
+ if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
+ if !results[0].IsNil() {
+ return nil, results[0].Interface().(error)
+ }
+ return nil, nil
+ }
+ return results[0].Interface(), nil
+ }
+
+ if len(results) == 2 && results[1].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
+ if !results[1].IsNil() {
+ return nil, results[1].Interface().(error)
+ }
+ return results[0].Interface(), nil
+ }
+
+ return nil, errors.New("unexpected return values from handler")
+}
diff --git a/internal/jsonrpc/types.go b/internal/jsonrpc/types.go
new file mode 100644
index 00000000..ac4f956c
--- /dev/null
+++ b/internal/jsonrpc/types.go
@@ -0,0 +1,32 @@
+package jsonrpc
+
+type JSONRPCRequest struct {
+ JSONRPC string `json:"jsonrpc"`
+ Method string `json:"method"`
+ Params map[string]interface{} `json:"params,omitempty"`
+ ID interface{} `json:"id,omitempty"`
+}
+
+type JSONRPCResponse struct {
+ JSONRPC string `json:"jsonrpc"`
+ Result interface{} `json:"result,omitempty"`
+ Error *JSONRPCResponseError `json:"error,omitempty"`
+ ID interface{} `json:"id"`
+}
+
+type JSONRPCResponseError struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Data interface{} `json:"data,omitempty"`
+}
+
+type JSONRPCEvent struct {
+ JSONRPC string `json:"jsonrpc"`
+ Method string `json:"method"`
+ Params interface{} `json:"params,omitempty"`
+}
+
+type RPCHandler struct {
+ Func interface{}
+ Params []string
+}
diff --git a/internal/plugin/database.go b/internal/plugin/database.go
new file mode 100644
index 00000000..6e669dce
--- /dev/null
+++ b/internal/plugin/database.go
@@ -0,0 +1,92 @@
+package plugin
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path"
+ "sync"
+)
+
+const databaseFile = pluginsFolder + "/plugins.json"
+
+type PluginDatabase struct {
+ // Map with the plugin name as the key
+ Plugins map[string]*PluginInstall `json:"plugins"`
+
+ saveMutex sync.Mutex
+}
+
+var pluginDatabase = PluginDatabase{}
+
+func (d *PluginDatabase) Load() error {
+ file, err := os.Open(databaseFile)
+ if os.IsNotExist(err) {
+ d.Plugins = make(map[string]*PluginInstall)
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("failed to open plugin database: %v", err)
+ }
+ defer file.Close()
+
+ if err := json.NewDecoder(file).Decode(d); err != nil {
+ return fmt.Errorf("failed to decode plugin database: %v", err)
+ }
+
+ return nil
+}
+
+func (d *PluginDatabase) Save() error {
+ d.saveMutex.Lock()
+ defer d.saveMutex.Unlock()
+
+ file, err := os.Create(databaseFile + ".tmp")
+ if err != nil {
+ return fmt.Errorf("failed to create plugin database tmp: %v", err)
+ }
+ defer file.Close()
+
+ encoder := json.NewEncoder(file)
+ encoder.SetIndent("", " ")
+ if err := encoder.Encode(d); err != nil {
+ return fmt.Errorf("failed to encode plugin database: %v", err)
+ }
+
+ if err := os.Rename(databaseFile+".tmp", databaseFile); err != nil {
+ return fmt.Errorf("failed to move plugin database to active file: %v", err)
+ }
+
+ return nil
+}
+
+// Find all extract directories that are not referenced in the Plugins map and remove them
+func (d *PluginDatabase) CleanupExtractDirectories() error {
+ extractDirectories, err := os.ReadDir(pluginsExtractsFolder)
+ if err != nil {
+ return fmt.Errorf("failed to read extract directories: %v", err)
+ }
+
+ for _, extractDir := range extractDirectories {
+ found := false
+ for _, pluginInstall := range d.Plugins {
+ for _, extractedFolder := range pluginInstall.ExtractedVersions {
+ if extractDir.Name() == extractedFolder {
+ found = true
+ break
+ }
+ }
+ if found {
+ break
+ }
+ }
+
+ if !found {
+ if err := os.RemoveAll(path.Join(pluginsExtractsFolder, extractDir.Name())); err != nil {
+ return fmt.Errorf("failed to remove extract directory: %v", err)
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/internal/plugin/extract.go b/internal/plugin/extract.go
new file mode 100644
index 00000000..9fd8bb80
--- /dev/null
+++ b/internal/plugin/extract.go
@@ -0,0 +1,95 @@
+package plugin
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "fmt"
+ "io"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "github.com/google/uuid"
+)
+
+const pluginsExtractsFolder = pluginsFolder + "/extracts"
+
+func init() {
+ _ = os.MkdirAll(pluginsExtractsFolder, 0755)
+}
+
+func extractPlugin(filePath string) (string, error) {
+ file, err := os.Open(filePath)
+ if err != nil {
+ return "", fmt.Errorf("failed to open file for extraction: %v", err)
+ }
+ defer file.Close()
+
+ var reader io.Reader = file
+ // TODO: there's probably a better way of doing this without relying on the file extension
+ if strings.HasSuffix(filePath, ".gz") {
+ gzipReader, err := gzip.NewReader(file)
+ if err != nil {
+ return "", fmt.Errorf("failed to create gzip reader: %v", err)
+ }
+ defer gzipReader.Close()
+ reader = gzipReader
+ }
+
+ destinationFolder := path.Join(pluginsExtractsFolder, uuid.New().String())
+ if err := os.MkdirAll(destinationFolder, 0755); err != nil {
+ return "", fmt.Errorf("failed to create extracts folder: %v", err)
+ }
+
+ if err := extractTarball(reader, destinationFolder); err != nil {
+ if err := os.RemoveAll(destinationFolder); err != nil {
+ return "", fmt.Errorf("failed to remove failed extraction folder: %v", err)
+ }
+
+ return "", fmt.Errorf("failed to extract tarball: %v", err)
+ }
+
+ return destinationFolder, nil
+}
+
+func extractTarball(reader io.Reader, destinationFolder string) error {
+ tarReader := tar.NewReader(reader)
+
+ for {
+ header, err := tarReader.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return fmt.Errorf("failed to read tar header: %v", err)
+ }
+
+ // Prevent path traversal attacks
+ targetPath := filepath.Join(destinationFolder, header.Name)
+ if !strings.HasPrefix(targetPath, filepath.Clean(destinationFolder)+string(os.PathSeparator)) {
+ return fmt.Errorf("tar file contains illegal path: %s", header.Name)
+ }
+
+ switch header.Typeflag {
+ case tar.TypeDir:
+ if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
+ return fmt.Errorf("failed to create directory: %v", err)
+ }
+ case tar.TypeReg:
+ file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode))
+ if err != nil {
+ return fmt.Errorf("failed to create file: %v", err)
+ }
+ defer file.Close()
+
+ if _, err := io.Copy(file, tarReader); err != nil {
+ return fmt.Errorf("failed to extract file: %v", err)
+ }
+ default:
+ return fmt.Errorf("unsupported tar entry type: %v", header.Typeflag)
+ }
+ }
+
+ return nil
+}
diff --git a/internal/plugin/install.go b/internal/plugin/install.go
new file mode 100644
index 00000000..dedf3291
--- /dev/null
+++ b/internal/plugin/install.go
@@ -0,0 +1,165 @@
+package plugin
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "path"
+ "syscall"
+)
+
+type PluginInstall struct {
+ Enabled bool `json:"enabled"`
+
+ // Current active version of the plugin
+ Version string `json:"version"`
+
+ // Map of a plugin version to the extracted directory
+ ExtractedVersions map[string]string `json:"extracted_versions"`
+
+ manifest *PluginManifest
+ runningVersion string
+ processManager *ProcessManager
+ rpcServer *PluginRpcServer
+}
+
+func (p *PluginInstall) GetManifest() (*PluginManifest, error) {
+ if p.manifest != nil {
+ return p.manifest, nil
+ }
+
+ manifest, err := readManifest(p.GetExtractedFolder())
+ if err != nil {
+ return nil, err
+ }
+
+ p.manifest = manifest
+ return manifest, nil
+}
+
+func (p *PluginInstall) GetExtractedFolder() string {
+ return p.ExtractedVersions[p.Version]
+}
+
+func (p *PluginInstall) GetStatus() (*PluginStatus, error) {
+ manifest, err := p.GetManifest()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get plugin manifest: %v", err)
+ }
+
+ status := PluginStatus{
+ PluginManifest: *manifest,
+ Enabled: p.Enabled,
+ }
+
+ // If the rpc server is connected and the plugin is reporting status, use that
+ if p.rpcServer != nil &&
+ p.rpcServer.status.Status != "disconnected" &&
+ p.rpcServer.status.Status != "unknown" {
+ status.Status = p.rpcServer.status.Status
+ status.Message = p.rpcServer.status.Message
+
+ if status.Status == "error" {
+ status.Message = p.rpcServer.status.Message
+ }
+ } else {
+ status.Status = "stopped"
+ if p.processManager != nil {
+ status.Status = "running"
+ if p.processManager.LastError != nil {
+ status.Status = "error"
+ status.Message = p.processManager.LastError.Error()
+ }
+ }
+ log.Printf("Status from process manager: %v", status.Status)
+ }
+
+ return &status, nil
+}
+
+func (p *PluginInstall) ReconcileSubprocess() error {
+ manifest, err := p.GetManifest()
+ if err != nil {
+ return fmt.Errorf("failed to get plugin manifest: %v", err)
+ }
+
+ versionRunning := p.runningVersion
+
+ versionShouldBeRunning := p.Version
+ if !p.Enabled {
+ versionShouldBeRunning = ""
+ }
+
+ log.Printf("Reconciling plugin %s running %v, should be running %v", manifest.Name, versionRunning, versionShouldBeRunning)
+
+ if versionRunning == versionShouldBeRunning {
+ log.Printf("Plugin %s is already running version %s", manifest.Name, versionRunning)
+ return nil
+ }
+
+ if p.processManager != nil {
+ log.Printf("Stopping plugin %s running version %s", manifest.Name, versionRunning)
+ p.processManager.Disable()
+ p.processManager = nil
+ p.runningVersion = ""
+ err = p.rpcServer.Stop()
+ if err != nil {
+ return fmt.Errorf("failed to stop rpc server: %v", err)
+ }
+ }
+
+ if versionShouldBeRunning == "" {
+ return nil
+ }
+
+ workingDir := path.Join(pluginsFolder, "working_dirs", p.manifest.Name)
+ err = os.MkdirAll(workingDir, 0755)
+ if err != nil {
+ return fmt.Errorf("failed to create working directory: %v", err)
+ }
+
+ p.rpcServer = NewPluginRpcServer(p, workingDir)
+ err = p.rpcServer.Start()
+ if err != nil {
+ return fmt.Errorf("failed to start rpc server: %v", err)
+ }
+
+ p.processManager = NewProcessManager(func() *exec.Cmd {
+ cmd := exec.Command(manifest.BinaryPath)
+ cmd.Dir = p.GetExtractedFolder()
+ cmd.Env = append(cmd.Env,
+ "JETKVM_PLUGIN_SOCK="+p.rpcServer.SocketPath(),
+ "JETKVM_PLUGIN_WORKING_DIR="+workingDir,
+ )
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ // Ensure that the process is killed when the parent dies
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Setpgid: true,
+ Pdeathsig: syscall.SIGKILL,
+ }
+ return cmd
+ })
+ p.processManager.StartMonitor()
+ p.processManager.Enable()
+ p.runningVersion = p.Version
+
+ // Clear out manifest so the new version gets pulled next time
+ p.manifest = nil
+
+ log.Printf("Started plugin %s version %s", manifest.Name, p.Version)
+ return nil
+}
+
+func (p *PluginInstall) Shutdown() {
+ if p.processManager != nil {
+ p.processManager.Disable()
+ p.processManager = nil
+ p.runningVersion = ""
+ }
+
+ if p.rpcServer != nil {
+ p.rpcServer.Stop()
+ }
+}
diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go
new file mode 100644
index 00000000..e72acdfb
--- /dev/null
+++ b/internal/plugin/plugin.go
@@ -0,0 +1,257 @@
+package plugin
+
+import (
+ "encoding/json"
+ "fmt"
+ "kvm/internal/storage"
+ "os"
+ "path"
+
+ "github.com/google/uuid"
+)
+
+const pluginsFolder = "/userdata/jetkvm/plugins"
+const pluginsUploadFolder = pluginsFolder + "/uploads"
+
+func init() {
+ _ = os.MkdirAll(pluginsUploadFolder, 0755)
+
+ if err := pluginDatabase.Load(); err != nil {
+ fmt.Printf("failed to load plugin database: %v\n", err)
+ }
+}
+
+// Starts all plugins that need to be started
+func ReconcilePlugins() {
+ for _, install := range pluginDatabase.Plugins {
+ err := install.ReconcileSubprocess()
+ if err != nil {
+ fmt.Printf("failed to reconcile subprocess for plugin: %v\n", err)
+ }
+ }
+}
+
+func GracefullyShutdownPlugins() {
+ for _, install := range pluginDatabase.Plugins {
+ install.Shutdown()
+ }
+}
+
+func RpcPluginStartUpload(filename string, size int64) (*storage.StorageFileUpload, error) {
+ sanitizedFilename, err := storage.SanitizeFilename(filename)
+ if err != nil {
+ return nil, err
+ }
+
+ filePath := path.Join(pluginsUploadFolder, sanitizedFilename)
+ uploadPath := filePath + ".incomplete"
+
+ if _, err := os.Stat(filePath); err == nil {
+ return nil, fmt.Errorf("file already exists: %s", sanitizedFilename)
+ }
+
+ var alreadyUploadedBytes int64 = 0
+ if stat, err := os.Stat(uploadPath); err == nil {
+ alreadyUploadedBytes = stat.Size()
+ }
+
+ uploadId := "plugin_" + uuid.New().String()
+ file, err := os.OpenFile(uploadPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open file for upload: %v", err)
+ }
+
+ storage.AddPendingUpload(uploadId, storage.PendingUpload{
+ File: file,
+ Size: size,
+ AlreadyUploadedBytes: alreadyUploadedBytes,
+ })
+
+ return &storage.StorageFileUpload{
+ AlreadyUploadedBytes: alreadyUploadedBytes,
+ DataChannel: uploadId,
+ }, nil
+}
+
+func RpcPluginExtract(filename string) (*PluginManifest, error) {
+ sanitizedFilename, err := storage.SanitizeFilename(filename)
+ if err != nil {
+ return nil, err
+ }
+
+ filePath := path.Join(pluginsUploadFolder, sanitizedFilename)
+ extractFolder, err := extractPlugin(filePath)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := os.Remove(filePath); err != nil {
+ return nil, fmt.Errorf("failed to delete uploaded file: %v", err)
+ }
+
+ manifest, err := readManifest(extractFolder)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get existing PluginInstall
+ install, ok := pluginDatabase.Plugins[manifest.Name]
+ if !ok {
+ install = &PluginInstall{
+ Enabled: false,
+ Version: manifest.Version,
+ ExtractedVersions: make(map[string]string),
+ }
+ }
+
+ _, ok = install.ExtractedVersions[manifest.Version]
+ if ok {
+ return nil, fmt.Errorf("this version has already been uploaded: %s", manifest.Version)
+ }
+
+ install.ExtractedVersions[manifest.Version] = extractFolder
+ pluginDatabase.Plugins[manifest.Name] = install
+
+ if err := pluginDatabase.Save(); err != nil {
+ return nil, fmt.Errorf("failed to save plugin database: %v", err)
+ }
+
+ return manifest, nil
+}
+
+func RpcPluginInstall(name string, version string) error {
+ pluginInstall, ok := pluginDatabase.Plugins[name]
+ if !ok {
+ return fmt.Errorf("plugin not found: %s", name)
+ }
+
+ if pluginInstall.Version == version && pluginInstall.Enabled {
+ fmt.Printf("Plugin %s is already installed with version %s\n", name, version)
+ return nil
+ }
+
+ _, ok = pluginInstall.ExtractedVersions[version]
+ if !ok {
+ return fmt.Errorf("plugin version not found: %s", version)
+ }
+
+ pluginInstall.Version = version
+ pluginInstall.Enabled = true
+ pluginDatabase.Plugins[name] = pluginInstall
+
+ if err := pluginDatabase.Save(); err != nil {
+ return fmt.Errorf("failed to save plugin database: %v", err)
+ }
+
+ err := pluginInstall.ReconcileSubprocess()
+ if err != nil {
+ return fmt.Errorf("failed to start plugin %s: %v", name, err)
+ }
+
+ // TODO: Determine if the old extract should be removed
+
+ return nil
+}
+
+func RpcPluginList() ([]PluginStatus, error) {
+ plugins := make([]PluginStatus, 0, len(pluginDatabase.Plugins))
+ for pluginName, plugin := range pluginDatabase.Plugins {
+ status, err := plugin.GetStatus()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get plugin status for %s: %v", pluginName, err)
+ }
+ plugins = append(plugins, *status)
+ }
+ return plugins, nil
+}
+
+func RpcPluginUpdateConfig(name string, enabled bool) (*PluginStatus, error) {
+ pluginInstall, ok := pluginDatabase.Plugins[name]
+ if !ok {
+ return nil, fmt.Errorf("plugin not found: %s", name)
+ }
+
+ pluginInstall.Enabled = enabled
+ pluginDatabase.Plugins[name] = pluginInstall
+
+ if err := pluginDatabase.Save(); err != nil {
+ return nil, fmt.Errorf("failed to save plugin database: %v", err)
+ }
+
+ err := pluginInstall.ReconcileSubprocess()
+ if err != nil {
+ return nil, fmt.Errorf("failed to stop plugin %s: %v", name, err)
+ }
+
+ status, err := pluginInstall.GetStatus()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get plugin status for %s: %v", name, err)
+ }
+ return status, nil
+}
+
+func RpcPluginUninstall(name string) error {
+ pluginInstall, ok := pluginDatabase.Plugins[name]
+ if !ok {
+ return fmt.Errorf("plugin not found: %s", name)
+ }
+
+ pluginInstall.Enabled = false
+
+ err := pluginInstall.ReconcileSubprocess()
+ if err != nil {
+ return fmt.Errorf("failed to stop plugin %s: %v", name, err)
+ }
+
+ delete(pluginDatabase.Plugins, name)
+ if err := pluginDatabase.Save(); err != nil {
+ return fmt.Errorf("failed to save plugin database: %v", err)
+ }
+
+ err = pluginDatabase.CleanupExtractDirectories()
+ if err != nil {
+ return fmt.Errorf("failed to cleanup extract directories: %v", err)
+ }
+
+ return nil
+}
+
+func readManifest(extractFolder string) (*PluginManifest, error) {
+ manifestPath := path.Join(extractFolder, "manifest.json")
+ manifestFile, err := os.Open(manifestPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open manifest file: %v", err)
+ }
+ defer manifestFile.Close()
+
+ manifest := PluginManifest{}
+ if err := json.NewDecoder(manifestFile).Decode(&manifest); err != nil {
+ return nil, fmt.Errorf("failed to read manifest file: %v", err)
+ }
+
+ if err := validateManifest(&manifest); err != nil {
+ return nil, fmt.Errorf("invalid manifest file: %v", err)
+ }
+
+ return &manifest, nil
+}
+
+func validateManifest(manifest *PluginManifest) error {
+ if manifest.ManifestVersion != "1" {
+ return fmt.Errorf("unsupported manifest version: %s", manifest.ManifestVersion)
+ }
+
+ if manifest.Name == "" {
+ return fmt.Errorf("missing plugin name")
+ }
+
+ if manifest.Version == "" {
+ return fmt.Errorf("missing plugin version")
+ }
+
+ if manifest.Homepage == "" {
+ return fmt.Errorf("missing plugin homepage")
+ }
+
+ return nil
+}
diff --git a/internal/plugin/process_manager.go b/internal/plugin/process_manager.go
new file mode 100644
index 00000000..9d647d88
--- /dev/null
+++ b/internal/plugin/process_manager.go
@@ -0,0 +1,119 @@
+package plugin
+
+import (
+ "fmt"
+ "log"
+ "os/exec"
+ "syscall"
+ "time"
+)
+
+// TODO: this can probably be defaulted to this, but overwritten on a per-plugin basis
+const (
+ gracefulShutdownDelay = 30 * time.Second
+ maxRestartBackoff = 30 * time.Second
+)
+
+type ProcessManager struct {
+ cmdGen func() *exec.Cmd
+ cmd *exec.Cmd
+ enabled bool
+ backoff time.Duration
+ shutdown chan struct{}
+ restartCh chan struct{}
+ LastError error
+}
+
+func NewProcessManager(commandGenerator func() *exec.Cmd) *ProcessManager {
+ return &ProcessManager{
+ cmdGen: commandGenerator,
+ enabled: true,
+ backoff: 250 * time.Millisecond,
+ shutdown: make(chan struct{}),
+ restartCh: make(chan struct{}, 1),
+ }
+}
+
+func (pm *ProcessManager) StartMonitor() {
+ go pm.monitor()
+}
+
+func (pm *ProcessManager) monitor() {
+ for {
+ select {
+ case <-pm.shutdown:
+ pm.terminate()
+ return
+ case <-pm.restartCh:
+ if pm.enabled {
+ go pm.runProcess()
+ }
+ }
+ }
+}
+
+func (pm *ProcessManager) runProcess() {
+ pm.LastError = nil
+ pm.cmd = pm.cmdGen()
+ log.Printf("Starting process: %v", pm.cmd)
+ err := pm.cmd.Start()
+ if err != nil {
+ log.Printf("Failed to start process: %v", err)
+ pm.LastError = fmt.Errorf("failed to start process: %w", err)
+ pm.scheduleRestart()
+ return
+ }
+
+ err = pm.cmd.Wait()
+ if err != nil {
+ log.Printf("Process exited: %v", err)
+ pm.LastError = fmt.Errorf("process exited with error: %w", err)
+ pm.scheduleRestart()
+ }
+}
+
+func (pm *ProcessManager) scheduleRestart() {
+ if pm.enabled {
+ log.Printf("Restarting process in %v...", pm.backoff)
+ time.Sleep(pm.backoff)
+ pm.backoff *= 2 // Exponential backoff
+ if pm.backoff > maxRestartBackoff {
+ pm.backoff = maxRestartBackoff
+ }
+ pm.restartCh <- struct{}{}
+ }
+}
+
+func (pm *ProcessManager) terminate() {
+ if pm.cmd.Process != nil {
+ log.Printf("Sending SIGTERM...")
+ pm.cmd.Process.Signal(syscall.SIGTERM)
+ select {
+ case <-time.After(gracefulShutdownDelay):
+ log.Printf("Forcing process termination...")
+ pm.cmd.Process.Kill()
+ case <-pm.waitForExit():
+ log.Printf("Process exited gracefully.")
+ }
+ }
+}
+
+func (pm *ProcessManager) waitForExit() <-chan struct{} {
+ done := make(chan struct{})
+ go func() {
+ pm.cmd.Wait()
+ close(done)
+ }()
+ return done
+}
+
+func (pm *ProcessManager) Enable() {
+ pm.enabled = true
+ pm.restartCh <- struct{}{}
+}
+
+func (pm *ProcessManager) Disable() {
+ pm.enabled = false
+ close(pm.shutdown)
+ pm.cmd.Wait()
+}
diff --git a/internal/plugin/rpc.go b/internal/plugin/rpc.go
new file mode 100644
index 00000000..dacb1d89
--- /dev/null
+++ b/internal/plugin/rpc.go
@@ -0,0 +1,174 @@
+package plugin
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "kvm/internal/jsonrpc"
+ "log"
+ "net"
+ "os"
+ "path"
+ "slices"
+ "time"
+)
+
+type PluginRpcStatus struct {
+ Status string `json:"status"`
+ Message string `json:"message,omitempty"`
+}
+
+var (
+ PluginRpcStatusDisconnected = PluginRpcStatus{"disconnected", ""}
+ PluginRpcStatusUnknown = PluginRpcStatus{"unknown", ""}
+ PluginRpcStatusLoading = PluginRpcStatus{"loading", ""}
+ PluginRpcStatusPendingConfiguration = PluginRpcStatus{"pending-configuration", ""}
+ PluginRpcStatusRunning = PluginRpcStatus{"running", ""}
+ PluginRpcStatusError = PluginRpcStatus{"error", ""}
+)
+
+type PluginRpcSupportedMethods struct {
+ SupportedRpcMethods []string `json:"supported_rpc_methods"`
+}
+
+type PluginRpcServer struct {
+ install *PluginInstall
+ workingDir string
+
+ listener net.Listener
+ status PluginRpcStatus
+}
+
+func NewPluginRpcServer(install *PluginInstall, workingDir string) *PluginRpcServer {
+ return &PluginRpcServer{
+ install: install,
+ workingDir: workingDir,
+ status: PluginRpcStatusDisconnected,
+ }
+}
+
+func (s *PluginRpcServer) Start() error {
+ socketPath := s.SocketPath()
+ _ = os.Remove(socketPath)
+ listener, err := net.Listen("unix", socketPath)
+ if err != nil {
+ return fmt.Errorf("failed to listen on socket: %v", err)
+ }
+ s.listener = listener
+
+ s.status = PluginRpcStatusDisconnected
+ go func() {
+ for {
+ conn, err := listener.Accept()
+ if err != nil {
+ // If the error indicates the listener is closed, break out
+ if opErr, ok := err.(*net.OpError); ok && opErr.Err.Error() == "use of closed network connection" {
+ log.Println("Listener closed, exiting accept loop.")
+ return
+ }
+
+ log.Printf("Failed to accept connection: %v", err)
+ continue
+ }
+ log.Printf("Accepted plugin rpc connection from %v", conn.RemoteAddr())
+
+ go s.handleConnection(conn)
+ }
+ }()
+
+ return nil
+}
+
+func (s *PluginRpcServer) Stop() error {
+ if s.listener != nil {
+ s.status = PluginRpcStatusDisconnected
+ return s.listener.Close()
+ }
+ return nil
+}
+
+func (s *PluginRpcServer) Status() PluginRpcStatus {
+ return s.status
+}
+
+func (s *PluginRpcServer) SocketPath() string {
+ return path.Join(s.workingDir, "plugin.sock")
+}
+
+func (s *PluginRpcServer) handleConnection(conn net.Conn) {
+ rpcserver := jsonrpc.NewJSONRPCRouter(conn, map[string]*jsonrpc.RPCHandler{})
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ go s.handleRpcStatus(ctx, rpcserver)
+
+ // Read from the conn and write into rpcserver.HandleMessage
+ buf := make([]byte, 65*1024)
+ for {
+ // TODO: if read 65k bytes, then likey there is more data to read... figure out how to handle this
+ n, err := conn.Read(buf)
+ if err != nil {
+ if errors.Is(err, net.ErrClosed) {
+ s.status = PluginRpcStatusDisconnected
+ } else {
+ log.Printf("Failed to read message: %v", err)
+ s.status = PluginRpcStatusError
+ s.status.Message = fmt.Errorf("failed to read message: %v", err).Error()
+ }
+ break
+ }
+
+ err = rpcserver.HandleMessage(buf[:n])
+ if err != nil {
+ log.Printf("Failed to handle message: %v", err)
+ s.status = PluginRpcStatusError
+ s.status.Message = fmt.Errorf("failed to handle message: %v", err).Error()
+ continue
+ }
+ }
+}
+
+func (s *PluginRpcServer) handleRpcStatus(ctx context.Context, rpcserver *jsonrpc.JSONRPCRouter) {
+ s.status = PluginRpcStatusUnknown
+
+ log.Printf("Plugin rpc server started. Getting supported methods...")
+ var supportedMethodsResponse PluginRpcSupportedMethods
+ err := rpcserver.Request("getPluginSupportedMethods", nil, &supportedMethodsResponse)
+ if err != nil {
+ log.Printf("Failed to get supported methods: %v", err)
+ s.status = PluginRpcStatusError
+ s.status.Message = fmt.Errorf("error getting supported methods: %v", err.Message).Error()
+ }
+
+ log.Printf("Plugin has supported methods: %v", supportedMethodsResponse.SupportedRpcMethods)
+
+ if !slices.Contains(supportedMethodsResponse.SupportedRpcMethods, "getPluginStatus") {
+ log.Printf("Plugin does not support getPluginStatus method")
+ return
+ }
+
+ ticker := time.NewTicker(1 * time.Second)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ var statusResponse PluginRpcStatus
+ err := rpcserver.Request("getPluginStatus", nil, &statusResponse)
+ if err != nil {
+ log.Printf("Failed to get status: %v", err)
+ if err, ok := err.Data.(error); ok && errors.Is(err, net.ErrClosed) {
+ s.status = PluginRpcStatusDisconnected
+ break
+ }
+
+ s.status = PluginRpcStatusError
+ s.status.Message = fmt.Errorf("error getting status: %v", err).Error()
+ continue
+ }
+
+ s.status = statusResponse
+ }
+ }
+}
diff --git a/internal/plugin/type.go b/internal/plugin/type.go
new file mode 100644
index 00000000..de1001a0
--- /dev/null
+++ b/internal/plugin/type.go
@@ -0,0 +1,18 @@
+package plugin
+
+type PluginManifest struct {
+ ManifestVersion string `json:"manifest_version"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Description string `json:"description,omitempty"`
+ Homepage string `json:"homepage"`
+ BinaryPath string `json:"bin"`
+ SystemMinVersion string `json:"system_min_version,omitempty"`
+}
+
+type PluginStatus struct {
+ PluginManifest
+ Enabled bool `json:"enabled"`
+ Status string `json:"status"`
+ Message string `json:"message,omitempty"`
+}
diff --git a/internal/storage/type.go b/internal/storage/type.go
new file mode 100644
index 00000000..ba7a1232
--- /dev/null
+++ b/internal/storage/type.go
@@ -0,0 +1,6 @@
+package storage
+
+type StorageFileUpload struct {
+ AlreadyUploadedBytes int64 `json:"alreadyUploadedBytes"`
+ DataChannel string `json:"dataChannel"`
+}
diff --git a/internal/storage/uploads.go b/internal/storage/uploads.go
new file mode 100644
index 00000000..48fdaf7a
--- /dev/null
+++ b/internal/storage/uploads.go
@@ -0,0 +1,34 @@
+package storage
+
+import (
+ "os"
+ "sync"
+)
+
+type PendingUpload struct {
+ File *os.File
+ Size int64
+ AlreadyUploadedBytes int64
+}
+
+var pendingUploads = make(map[string]PendingUpload)
+var pendingUploadsMutex sync.Mutex
+
+func GetPendingUpload(uploadId string) (PendingUpload, bool) {
+ pendingUploadsMutex.Lock()
+ defer pendingUploadsMutex.Unlock()
+ upload, ok := pendingUploads[uploadId]
+ return upload, ok
+}
+
+func AddPendingUpload(uploadId string, upload PendingUpload) {
+ pendingUploadsMutex.Lock()
+ defer pendingUploadsMutex.Unlock()
+ pendingUploads[uploadId] = upload
+}
+
+func DeletePendingUpload(uploadId string) {
+ pendingUploadsMutex.Lock()
+ defer pendingUploadsMutex.Unlock()
+ delete(pendingUploads, uploadId)
+}
diff --git a/internal/storage/utils.go b/internal/storage/utils.go
new file mode 100644
index 00000000..e622fc23
--- /dev/null
+++ b/internal/storage/utils.go
@@ -0,0 +1,19 @@
+package storage
+
+import (
+ "errors"
+ "path/filepath"
+ "strings"
+)
+
+func SanitizeFilename(filename string) (string, error) {
+ cleanPath := filepath.Clean(filename)
+ if filepath.IsAbs(cleanPath) || strings.Contains(cleanPath, "..") {
+ return "", errors.New("invalid filename")
+ }
+ sanitized := filepath.Base(cleanPath)
+ if sanitized == "." || sanitized == string(filepath.Separator) {
+ return "", errors.New("invalid filename")
+ }
+ return sanitized, nil
+}
diff --git a/jsonrpc.go b/jsonrpc.go
index 2ce5f189..3e4d1291 100644
--- a/jsonrpc.go
+++ b/jsonrpc.go
@@ -5,50 +5,52 @@ import (
"encoding/json"
"errors"
"fmt"
+ "kvm/internal/jsonrpc"
+ "kvm/internal/plugin"
"log"
"os"
"os/exec"
"path/filepath"
- "reflect"
+ "syscall"
"github.com/pion/webrtc/v4"
)
-type JSONRPCRequest struct {
- JSONRPC string `json:"jsonrpc"`
- Method string `json:"method"`
- Params map[string]interface{} `json:"params,omitempty"`
- ID interface{} `json:"id,omitempty"`
+type DataChannelWriter struct {
+ dataChannel *webrtc.DataChannel
}
-type JSONRPCResponse struct {
- JSONRPC string `json:"jsonrpc"`
- Result interface{} `json:"result,omitempty"`
- Error interface{} `json:"error,omitempty"`
- ID interface{} `json:"id"`
+func NewDataChannelWriter(dataChannel *webrtc.DataChannel) *DataChannelWriter {
+ return &DataChannelWriter{
+ dataChannel: dataChannel,
+ }
}
-type JSONRPCEvent struct {
- JSONRPC string `json:"jsonrpc"`
- Method string `json:"method"`
- Params interface{} `json:"params,omitempty"`
+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 {
- log.Println("Error marshalling JSONRPC response:", err)
- return
- }
- err = session.RPCChannel.SendText(string(responseBytes))
+func (w *DataChannelWriter) Write(data []byte) (int, error) {
+ err := w.dataChannel.SendText(string(data))
if err != nil {
log.Println("Error sending JSONRPC response:", err)
- return
+ return 0, err
}
+ return len(data), nil
+}
+
+func NewDataChannelJsonRpcRouter(dataChannel *webrtc.DataChannel) *jsonrpc.JSONRPCRouter {
+ return jsonrpc.NewJSONRPCRouter(
+ NewDataChannelWriter(dataChannel),
+ rpcHandlers,
+ )
}
+// TODO: embed this into the session's rpc server
func writeJSONRPCEvent(event string, params interface{}, session *Session) {
- request := JSONRPCEvent{
+ request := jsonrpc.JSONRPCEvent{
JSONRPC: "2.0",
Method: event,
Params: params,
@@ -69,60 +71,6 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
}
}
-func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
- var request JSONRPCRequest
- err := json.Unmarshal(message.Data, &request)
- if err != nil {
- errorResponse := JSONRPCResponse{
- JSONRPC: "2.0",
- Error: map[string]interface{}{
- "code": -32700,
- "message": "Parse error",
- },
- ID: 0,
- }
- writeJSONRPCResponse(errorResponse, session)
- return
- }
-
- //log.Printf("Received RPC request: Method=%s, Params=%v, ID=%d", request.Method, request.Params, request.ID)
- handler, ok := rpcHandlers[request.Method]
- if !ok {
- errorResponse := JSONRPCResponse{
- JSONRPC: "2.0",
- Error: map[string]interface{}{
- "code": -32601,
- "message": "Method not found",
- },
- ID: request.ID,
- }
- writeJSONRPCResponse(errorResponse, session)
- return
- }
-
- result, err := callRPCHandler(handler, request.Params)
- if err != nil {
- errorResponse := JSONRPCResponse{
- JSONRPC: "2.0",
- Error: map[string]interface{}{
- "code": -32603,
- "message": "Internal error",
- "data": err.Error(),
- },
- ID: request.ID,
- }
- writeJSONRPCResponse(errorResponse, session)
- return
- }
-
- response := JSONRPCResponse{
- JSONRPC: "2.0",
- Result: result,
- ID: request.ID,
- }
- writeJSONRPCResponse(response, session)
-}
-
func rpcPing() (string, error) {
return "pong", nil
}
@@ -131,6 +79,18 @@ func rpcGetDeviceID() (string, error) {
return GetDeviceID(), nil
}
+func rpcGetKeyboardLayout() (string, error) {
+ return config.KeyboardLayout, nil
+}
+
+func rpcSetKeyboardLayout(KeyboardLayout string) (string, error) {
+ config.KeyboardLayout = KeyboardLayout
+ if err := SaveConfig(); err != nil {
+ return config.KeyboardLayout, fmt.Errorf("failed to save config: %w", err)
+ }
+ return KeyboardLayout, nil
+}
+
var streamFactor = 1.0
func rpcGetStreamQualityFactor() (float64, error) {
@@ -183,6 +143,12 @@ func rpcSetEDID(edid string) error {
if err != nil {
return err
}
+
+ // Save EDID to config, allowing it to be restored on reboot.
+ LoadConfig()
+ config.EdidString = edid
+ SaveConfig()
+
return nil
}
@@ -219,6 +185,56 @@ func rpcTryUpdate() error {
return nil
}
+func rpcSetBacklightSettings(params BacklightSettings) error {
+ LoadConfig()
+
+ 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)
+ }
+
+ log.Printf("rpc: display: settings applied, max_brightness: %d, dim after: %ds, off after: %ds", config.DisplayMaxBrightness, config.DisplayDimAfterSec, config.DisplayOffAfterSec)
+
+ // 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)
+ return nil
+}
+
+func rpcGetBacklightSettings() (*BacklightSettings, error) {
+ LoadConfig()
+
+ return &BacklightSettings{
+ MaxBrightness: config.DisplayMaxBrightness,
+ DimAfter: int(config.DisplayDimAfterSec),
+ OffAfter: int(config.DisplayOffAfterSec),
+ }, nil
+}
+
const (
devModeFile = "/userdata/jetkvm/devmode.enable"
sshKeyDir = "/userdata/dropbear/.ssh"
@@ -315,108 +331,6 @@ func rpcSetSSHKeyState(sshKey string) error {
return nil
}
-func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, 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()
- args := make([]reflect.Value, numParams)
- // Get the parameter names from the RPCHandler
- paramNames := handler.Params
-
- if len(paramNames) != numParams {
- return nil, errors.New("mismatch between handler parameters and defined parameter names")
- }
-
- for i := 0; i < numParams; i++ {
- paramType := handlerType.In(i)
- paramName := paramNames[i]
- paramValue, ok := params[paramName]
- if !ok {
- return nil, errors.New("missing parameter: " + paramName)
- }
-
- 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", intValue)
- }
- 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", err)
- }
-
- newStruct := reflect.New(paramType).Interface()
- if err := json.Unmarshal(jsonData, newStruct); err != nil {
- return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v", err)
- }
- args[i] = reflect.ValueOf(newStruct).Elem()
- } else {
- return nil, fmt.Errorf("invalid parameter type for: %s", paramName)
- }
- } else {
- args[i] = convertedValue.Convert(paramType)
- }
- }
-
- results := handlerValue.Call(args)
-
- if len(results) == 0 {
- return nil, nil
- }
-
- if len(results) == 1 {
- if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
- if !results[0].IsNil() {
- return nil, results[0].Interface().(error)
- }
- return nil, nil
- }
- return results[0].Interface(), nil
- }
-
- if len(results) == 2 && results[1].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
- if !results[1].IsNil() {
- return nil, results[1].Interface().(error)
- }
- return results[0].Interface(), nil
- }
-
- return nil, errors.New("unexpected return values from handler")
-}
-
-type RPCHandler struct {
- Func interface{}
- Params []string
-}
-
func rpcSetMassStorageMode(mode string) (string, error) {
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
var cdrom bool
@@ -478,6 +392,29 @@ func rpcSetUsbEmulationState(enabled bool) error {
}
}
+func rpcGetUsbConfig() (UsbConfig, error) {
+ LoadConfig()
+ return config.UsbConfig, nil
+}
+
+func rpcSetUsbConfig(usbConfig UsbConfig) error {
+ LoadConfig()
+ config.UsbConfig = usbConfig
+
+ err := UpdateGadgetConfig()
+ if err != nil {
+ return fmt.Errorf("failed to write gadget config: %w", err)
+ }
+
+ err = SaveConfig()
+ if err != nil {
+ return fmt.Errorf("failed to save usb config: %w", err)
+ }
+
+ log.Printf("[jsonrpc.go:rpcSetUsbConfig] usb config set to %s", usbConfig)
+ return nil
+}
+
func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) {
LoadConfig()
if config.WakeOnLanDevices == nil {
@@ -507,8 +444,13 @@ func rpcResetConfig() error {
return nil
}
+func rpcRebootDevice() {
+ syscall.Sync()
+ syscall.Reboot(syscall.LINUX_REBOOT_CMD_RESTART)
+}
+
// TODO: replace this crap with code generator
-var rpcHandlers = map[string]RPCHandler{
+var rpcHandlers = map[string]*jsonrpc.RPCHandler{
"ping": {Func: rpcPing},
"getDeviceID": {Func: rpcGetDeviceID},
"deregisterDevice": {Func: rpcDeregisterDevice},
@@ -523,6 +465,8 @@ var rpcHandlers = map[string]RPCHandler{
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
"getJigglerState": {Func: rpcGetJigglerState},
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
+ "getKeyboardLayout": {Func: rpcGetKeyboardLayout},
+ "setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"kbLayout"}},
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
@@ -542,6 +486,8 @@ var rpcHandlers = map[string]RPCHandler{
"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},
@@ -554,4 +500,13 @@ var rpcHandlers = map[string]RPCHandler{
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},
+ "pluginStartUpload": {Func: plugin.RpcPluginStartUpload, Params: []string{"filename", "size"}},
+ "pluginExtract": {Func: plugin.RpcPluginExtract, Params: []string{"filename"}},
+ "pluginInstall": {Func: plugin.RpcPluginInstall, Params: []string{"name", "version"}},
+ "pluginList": {Func: plugin.RpcPluginList},
+ "pluginUpdateConfig": {Func: plugin.RpcPluginUpdateConfig, Params: []string{"name", "enabled"}},
+ "pluginUninstall": {Func: plugin.RpcPluginUninstall, Params: []string{"name"}},
+ "setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
+ "getBacklightSettings": {Func: rpcGetBacklightSettings},
+ "rebootDevice": {Func: rpcRebootDevice},
}
diff --git a/main.go b/main.go
index 7ff771f5..1b02a985 100644
--- a/main.go
+++ b/main.go
@@ -2,6 +2,7 @@ package kvm
import (
"context"
+ "kvm/internal/plugin"
"log"
"net/http"
"os"
@@ -71,10 +72,13 @@ func Main() {
if config.CloudToken != "" {
go RunWebsocketClient()
}
+ go plugin.ReconcilePlugins()
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
log.Println("JetKVM Shutting Down")
+
+ plugin.GracefullyShutdownPlugins()
//if fuseServer != nil {
// err := setMassStorageImage(" ")
// if err != nil {
diff --git a/native.go b/native.go
index d34ab07b..1bd8429d 100644
--- a/native.go
+++ b/native.go
@@ -152,6 +152,9 @@ func handleCtrlClient(conn net.Conn) {
ctrlSocketConn = conn
+ // Restore HDMI EDID if applicable
+ go restoreHdmiEdid()
+
readBuf := make([]byte, 4096)
for {
n, err := conn.Read(readBuf)
@@ -304,3 +307,16 @@ func ensureBinaryUpdated(destPath string) error {
return nil
}
+
+// Restore the HDMI EDID value from the config.
+// Called after successful connection to jetkvm_native.
+func restoreHdmiEdid() {
+ LoadConfig()
+ if config.EdidString != "" {
+ logger.Infof("Restoring HDMI EDID to %v", config.EdidString)
+ _, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString})
+ if err != nil {
+ logger.Errorf("Failed to restore HDMI EDID: %v", err)
+ }
+ }
+}
diff --git a/network.go b/network.go
index f461e453..ee88d051 100644
--- a/network.go
+++ b/network.go
@@ -6,6 +6,7 @@ import (
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"net"
+ "os/exec"
"time"
"github.com/vishvananda/netlink"
@@ -25,6 +26,23 @@ type LocalIpInfo struct {
MAC string
}
+// setDhcpClientState sends signals to udhcpc to change it's current mode
+// of operation. Setting active to true will force udhcpc to renew the DHCP lease.
+// Setting active to false will put udhcpc into idle mode.
+func setDhcpClientState(active bool) {
+ var signal string;
+ if active {
+ signal = "-SIGUSR1"
+ } else {
+ signal = "-SIGUSR2"
+ }
+
+ cmd := exec.Command("/usr/bin/killall", signal, "udhcpc");
+ if err := cmd.Run(); err != nil {
+ fmt.Printf("network: setDhcpClientState: failed to change udhcpc state: %s\n", err)
+ }
+}
+
func checkNetworkState() {
iface, err := netlink.LinkByName("eth0")
if err != nil {
@@ -47,9 +65,26 @@ func checkNetworkState() {
fmt.Printf("failed to get addresses for eth0: %v\n", err)
}
+ // If the link is going down, put udhcpc into idle mode.
+ // If the link is coming back up, activate udhcpc and force it to renew the lease.
+ if newState.Up != networkState.Up {
+ setDhcpClientState(newState.Up)
+ }
+
for _, addr := range addrs {
if addr.IP.To4() != nil {
- newState.IPv4 = addr.IP.String()
+ if !newState.Up && networkState.Up {
+ // If the network is going down, remove all IPv4 addresses from the interface.
+ fmt.Printf("network: state transitioned to down, removing IPv4 address %s\n", addr.IP.String())
+ err := netlink.AddrDel(iface, &addr)
+ if err != nil {
+ fmt.Printf("network: failed to delete %s", addr.IP.String())
+ }
+
+ newState.IPv4 = "..."
+ } else {
+ newState.IPv4 = addr.IP.String()
+ }
} else if addr.IP.To16() != nil && newState.IPv6 == "" {
newState.IPv6 = addr.IP.String()
}
diff --git a/next_deploy.sh b/next_deploy.sh
new file mode 100755
index 00000000..2b573986
--- /dev/null
+++ b/next_deploy.sh
@@ -0,0 +1,81 @@
+# Exit immediately if a command exits with a non-zero status
+set -e
+
+# Function to display help message
+show_help() {
+ echo "Usage: $0 [options] -r "
+ echo
+ echo "Required:"
+ echo " -r, --remote Remote host IP address"
+ echo
+ echo "Optional:"
+ echo " -u, --user Remote username (default: root)"
+ echo " --help Display this help message"
+ echo
+ echo "Example:"
+ echo " $0 -r 192.168.0.17"
+ echo " $0 -r 192.168.0.17 -u admin"
+ exit 0
+}
+
+# Default values
+REMOTE_USER="root"
+REMOTE_PATH="/userdata/jetkvm/bin"
+
+# Parse command line arguments
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ -r|--remote)
+ REMOTE_HOST="$2"
+ shift 2
+ ;;
+ -u|--user)
+ REMOTE_USER="$2"
+ shift 2
+ ;;
+ --help)
+ show_help
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1"
+ show_help
+ exit 1
+ ;;
+ esac
+done
+
+# Verify required parameters
+if [ -z "$REMOTE_HOST" ]; then
+ echo "Error: Remote IP is a required parameter"
+ show_help
+fi
+
+# Check if the binary has been built at ./bin/next/jetkvm_app
+if [ ! -f bin/next/jetkvm_app ]; then
+ echo "Error: Binary not found at ./bin/next/jetkvm_app, run make build_next."
+ exit 1
+fi
+
+# Change directory to the binary output directory
+cd bin/next
+
+# Copy the binary to the remote host
+cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_next"
+
+# Deploy and run the application on the remote host
+ssh "${REMOTE_USER}@${REMOTE_HOST}" ash < state.remoteVirtualMediaState,
);
const developerMode = useSettingsStore(state => state.developerMode);
+ const hdmiState = useVideoStore(state => state.hdmiState);
// This is the only way to get a reliable state change for the popover
// at time of writing this there is no mount, or unmount event for the popover
@@ -52,6 +56,8 @@ export default function Actionbar({
[setDisableFocusTrap],
);
+ const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
+
return (
+ {
+ // Revalidate the current route to refresh the local device status and dependent UI components
+ revalidator.revalidate();
+ setIsLocalAuthDialogOpen(x);
+ }}
+ />
+ {
+ // Revalidate the current route to refresh the local device status and dependent UI components
+ revalidator.revalidate();
+ setIsUsbConfigDialogOpen(x);
+ }}
+ />
- {
- // Revalidate the current route to refresh the local device status and dependent UI components
- revalidator.revalidate();
- setIsLocalAuthDialogOpen(x);
- }}
- />
-