Compare commits

...

37 Commits

Author SHA1 Message Date
Siyuan Miao 69ebe52718 chore: use dynamic duration for scheduleAutoRelease 2025-09-17 18:53:16 +02:00
Siyuan Miao da176f73e1 clean up code 2025-09-17 17:21:27 +02:00
Siyuan Miao 8088855d26 gofmt 2025-09-17 17:06:08 +02:00
Adam Shiervani ba4a835755 refactor: enhance keep-alive handling and jitter compensation in HID RPC
- Implemented staleness guard to ignore outdated keep-alive packets.
- Added jitter compensation logic to adjust timer extensions based on packet arrival times.
- Introduced new methods for managing keep-alive state and reset functionality in the Session struct.
- Updated auto-release delay mechanism to use dynamic durations based on keep-alive timing.
- Adjusted keep-alive interval in the UI to improve responsiveness.
2025-09-17 16:52:25 +02:00
Adam Shiervani 7edf621c38 fix: update auto-release keyboard interval to 225 2025-09-17 11:34:07 +02:00
Adam Shiervani 6d85f8505c fix: log warning on keypress report failure 2025-09-17 11:34:07 +02:00
Adam Shiervani 21fe0e3be9 fix: handle error in key release process and log warnings 2025-09-17 11:34:07 +02:00
Adam Shiervani 97f9abf652 refactor: remove unused NewKeypressKeepAliveMessage function from HID RPC 2025-09-17 11:34:07 +02:00
Adam Shiervani 37acc11b26 refactor: replace console.warn with console.info for HID RPC channel events 2025-09-17 11:34:07 +02:00
Adam Shiervani c409e46292 refactor: send keypress as early as possible 2025-09-17 11:34:07 +02:00
Adam Shiervani 9c9833587a fix: correct Windows default auto-repeat delay comment from 1ms to 1s 2025-09-17 11:34:07 +02:00
Adam Shiervani 9b9ec0e6f4 refactor: enhance keyboard auto-release functionality and key state management 2025-09-17 11:34:07 +02:00
Adam Shiervani b430384ca8 refactor: simplify HID RPC keyboard input handling and improve key state management
- Updated `handleHidRPCKeyboardInput` to return errors directly instead of keys down state.
- Refactored `rpcKeyboardReport` and `rpcKeypressReport` to return errors instead of states.
- Introduced a queue for managing key down state updates in the `Session` struct to prevent input handling stalls.
- Adjusted the `UpdateKeysDown` method to handle state changes more efficiently.
- Removed unnecessary logging and commented-out code for clarity.
2025-09-17 11:34:07 +02:00
Siyuan Miao 23d8200b92 chore: use the proper key to send release command 2025-09-17 11:34:07 +02:00
Siyuan Miao b99d590c85 chore: add timeout for KeypressReport 2025-09-17 11:34:07 +02:00
Siyuan Miao 9e3caf00b8 chore: add trace log for writeWithTimeout 2025-09-17 11:34:07 +02:00
Siyuan Miao e09c6daccd fix: handshake won't happen if webrtc reconnects 2025-09-17 11:34:07 +02:00
Siyuan Miao ff1fecdceb fix: should cancelAutorelease when pressed is true 2025-09-17 11:34:07 +02:00
Siyuan Miao a7fd50d876 chore: update comment for KEEPALIVE_INTERVAL 2025-09-17 11:34:07 +02:00
Siyuan Miao 60c9b304e5 chore: update logging for kbdAutoReleaseLock 2025-09-17 11:34:07 +02:00
Siyuan Miao 178ec879c6 chore: adjust auto release key interval 2025-09-17 11:34:07 +02:00
Siyuan Miao d829aa36f0 chore: use ordered unreliable channel for pointer events 2025-09-17 11:34:06 +02:00
Siyuan Miao 24fafed574 chore: use unreliable channel to send keepalive events 2025-09-17 11:34:06 +02:00
Siyuan Miao a1259c7414 clean up logging 2025-09-17 11:34:06 +02:00
Siyuan Miao 144d5fcd73 remove logging 2025-09-17 11:34:06 +02:00
Siyuan Miao 36cb18d2e1 send keepalive when pressing the key 2025-09-17 11:34:06 +02:00
Siyuan Miao 6f4787ad17 feat: release keyPress automatically 2025-09-17 11:34:06 +02:00
Marc Brooks cf679978be
fix(timesync): ensure that auto-update waits for time sync (#609)
- Added check to not attempt auto update if time sync is needed and not yet successful (delays 30 second to recheck).
- Added resync of time when DHCP or link state changes if online
- Added conditional* fallback from configured* NTP servers to the IP-named NTP servers, and then to the DNS named ones if that fails
- Added conditional* fallback from the configured* HTTP servers to the default DNS named ones.
- Uses the configuration* option for how many queries to run in parallel
- Added known static IPs for time servers (in case DNS resolution isn't up yet)
- Added time.cloudflare.com to fall-back NTP servers
- Added fallback to NTP via hostnames
- Logs the resultant time (and mode)
2025-09-16 15:37:02 +02:00
Marc Brooks 80a8b9e9e3
feat: Adds IPv6 disabling feature (#803)
* Allow disabling IPv6

Simply ignores any IPv6 addresses in the lease and doesn't offer them to the RPC
Also fixed display issue for IPv6 link local address.
Fixes https://github.com/orgs/jetkvm/projects/7/views/1?pane=issue&itemId=122761546&issue=jetkvm%7Ckvm%7C685

* Don't listen on disabled addresses in mDNS or web server.

* We have to set the IPv4 and IPv6 modes on the server.
2025-09-16 12:44:56 +02:00
Aveline 1717549578
fix: goroutine leak issue of cloudBlink (#801)
* fix: goroutine leak issue of cloudBlink

* chore: add lock and allow context to be cancelled earlier
2025-09-12 18:30:35 +02:00
Aveline 37b1a8bf34
docs: update pprof section of DEVELOPMENT.md (#802) 2025-09-12 11:11:28 +02:00
Marc Brooks ca8b06f4cf
chore: enhance the gzip and cacheable handling of static files
Add SVG and ICO to cacheable files.
Emit robots.txt directly.
Recognize WOFF2 (font) files as assets (so the get the immutable treatment)
Pre-gzip the entire /static/ directory (not just /static/assets/) and include SVG, ICO, and HTML files
Ensure fonts.css is processed by vite/rollup so that the preload and css reference the same immutable files (which get long-cached with hashes)
Add CircularXXWeb-Black to the preload list as it is used in the hot-path.
Handle system-driven color-scheme changes from dark to light correctly.
2025-09-12 08:41:41 +02:00
Aveline 33e099f258
update netboot.xyz-multiarch.iso to 2.0.88 (#799)
* chore: update netboot.xyz-multiarch.iso to 2.0.88

* feat: add script to update netboot.xyz iso
2025-09-12 08:41:17 +02:00
Aveline ea068414dc
feat: validate ssh public key before saving (#794)
* feat: validate ssh public key before saving

* fix: TestValidSSHKeyTypes
2025-09-11 23:32:40 +02:00
Adam Shiervani 8d1a66806c
refactor(ui): Don't fetch KeybardAndMouse Icon on every re-render (#795) 2025-09-11 19:57:35 +02:00
Aveline 6202e3cafa
chore: serve pre-compressed static files (#793) 2025-09-11 19:17:15 +02:00
dependabot[bot] c866230711
build(deps-dev): bump vite (#788)
Bumps the npm_and_yarn group with 1 update in the /ui directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 7.1.4 to 7.1.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.5
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-11 12:07:52 +02:00
43 changed files with 1482 additions and 400 deletions

View File

@ -301,13 +301,14 @@ export JETKVM_PROXY_URL="ws://<IP>"
### Performance Profiling
```bash
# Enable profiling
go build -o bin/jetkvm_app -ldflags="-X main.enableProfiling=true" cmd/main.go
1. Enable `Developer Mode` on your JetKVM device
2. Add a password on the `Access` tab
```bash
# Access profiling
curl http://<IP>:6060/debug/pprof/
curl http://api:$JETKVM_PASSWORD@YOUR_DEVICE_IP/developer/pprof/
```
### Advanced Environment Variables
```bash

View File

@ -62,7 +62,22 @@ build_dev_test: build_test2json build_gotestsum
tar czfv device-tests.tar.gz -C $(BIN_DIR)/tests .
frontend:
cd ui && npm ci && npm run build:device
cd ui && npm ci && npm run build:device && \
find ../static/ \
-type f \
\( -name '*.js' \
-o -name '*.css' \
-o -name '*.html' \
-o -name '*.ico' \
-o -name '*.png' \
-o -name '*.jpg' \
-o -name '*.jpeg' \
-o -name '*.gif' \
-o -name '*.svg' \
-o -name '*.webp' \
-o -name '*.woff2' \
\) \
-exec sh -c 'gzip -9 -kfv {}' \;
dev_release: frontend build_dev
@echo "Uploading release..."

View File

@ -1,6 +1,7 @@
package kvm
import (
"context"
"errors"
"fmt"
"os"
@ -110,12 +111,6 @@ func clearDisplayState() {
currentScreen = "ui_Boot_Screen"
}
var (
cloudBlinkLock sync.Mutex = sync.Mutex{}
cloudBlinkStopped bool
cloudBlinkTicker *time.Ticker
)
func updateDisplay() {
updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4String())
if usbState == "configured" {
@ -152,48 +147,81 @@ func updateDisplay() {
stopCloudBlink()
case CloudConnectionStateConnecting:
_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png")
startCloudBlink()
restartCloudBlink()
case CloudConnectionStateConnected:
_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png")
stopCloudBlink()
}
}
func startCloudBlink() {
if cloudBlinkTicker == nil {
cloudBlinkTicker = time.NewTicker(2 * time.Second)
} else {
// do nothing if the blink isn't stopped
if cloudBlinkStopped {
cloudBlinkLock.Lock()
defer cloudBlinkLock.Unlock()
const (
cloudBlinkInterval = 2 * time.Second
cloudBlinkDuration = 1 * time.Second
)
cloudBlinkStopped = false
cloudBlinkTicker.Reset(2 * time.Second)
var (
cloudBlinkTicker *time.Ticker
cloudBlinkCancel context.CancelFunc
cloudBlinkLock = sync.Mutex{}
)
func doCloudBlink(ctx context.Context) {
for range cloudBlinkTicker.C {
if cloudConnectionState != CloudConnectionStateConnecting {
continue
}
_, _ = lvObjFadeOut("ui_Home_Header_Cloud_Status_Icon", uint32(cloudBlinkDuration.Milliseconds()))
select {
case <-ctx.Done():
return
case <-time.After(cloudBlinkDuration):
}
_, _ = lvObjFadeIn("ui_Home_Header_Cloud_Status_Icon", uint32(cloudBlinkDuration.Milliseconds()))
select {
case <-ctx.Done():
return
case <-time.After(cloudBlinkDuration):
}
}
}
go func() {
for range cloudBlinkTicker.C {
if cloudConnectionState != CloudConnectionStateConnecting {
continue
}
_, _ = lvObjFadeOut("ui_Home_Header_Cloud_Status_Icon", 1000)
time.Sleep(1000 * time.Millisecond)
_, _ = lvObjFadeIn("ui_Home_Header_Cloud_Status_Icon", 1000)
time.Sleep(1000 * time.Millisecond)
}
}()
func restartCloudBlink() {
stopCloudBlink()
startCloudBlink()
}
func startCloudBlink() {
cloudBlinkLock.Lock()
defer cloudBlinkLock.Unlock()
if cloudBlinkTicker == nil {
cloudBlinkTicker = time.NewTicker(cloudBlinkInterval)
} else {
cloudBlinkTicker.Reset(cloudBlinkInterval)
}
ctx, cancel := context.WithCancel(context.Background())
cloudBlinkCancel = cancel
go doCloudBlink(ctx)
}
func stopCloudBlink() {
cloudBlinkLock.Lock()
defer cloudBlinkLock.Unlock()
if cloudBlinkCancel != nil {
cloudBlinkCancel()
cloudBlinkCancel = nil
}
if cloudBlinkTicker != nil {
cloudBlinkTicker.Stop()
}
cloudBlinkLock.Lock()
defer cloudBlinkLock.Unlock()
cloudBlinkStopped = true
}
var (

3
go.mod
View File

@ -12,6 +12,7 @@ require (
github.com/gin-contrib/logger v1.2.6
github.com/gin-gonic/gin v1.10.1
github.com/go-co-op/gocron/v2 v2.16.5
github.com/google/flatbuffers v25.2.10+incompatible
github.com/google/uuid v1.6.0
github.com/guregu/null/v6 v6.0.0
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f
@ -23,6 +24,7 @@ require (
github.com/prometheus/common v0.66.0
github.com/prometheus/procfs v0.17.0
github.com/psanford/httpreadat v0.1.0
github.com/rs/xid v1.6.0
github.com/rs/zerolog v1.34.0
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f
github.com/stretchr/testify v1.11.1
@ -81,6 +83,7 @@ require (
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/vearutop/statigz v1.5.0 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/arch v0.18.0 // indirect

5
go.sum
View File

@ -53,6 +53,8 @@ github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAu
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@ -152,6 +154,7 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
@ -171,6 +174,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vearutop/statigz v1.5.0 h1:FuWwZiT82yBw4xbWdWIawiP2XFTyEPhIo8upRxiKLqk=
github.com/vearutop/statigz v1.5.0/go.mod h1:oHmjFf3izfCO804Di1ZjB666P3fAlVzJEx2k6jNt/Gk=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=

106
hidrpc.go
View File

@ -6,6 +6,7 @@ import (
"github.com/jetkvm/kvm/internal/hidrpc"
"github.com/jetkvm/kvm/internal/usbgadget"
"github.com/rs/zerolog"
)
func handleHidRPCMessage(message hidrpc.Message, session *Session) {
@ -24,11 +25,9 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
}
session.hidRPCAvailable = true
case hidrpc.TypeKeypressReport, hidrpc.TypeKeyboardReport:
keysDownState, err := handleHidRPCKeyboardInput(message)
if keysDownState != nil {
session.reportHidRPCKeysDownState(*keysDownState)
}
rpcErr = err
rpcErr = handleHidRPCKeyboardInput(message)
case hidrpc.TypeKeypressKeepAliveReport:
rpcErr = handleHidRPCKeypressKeepAlive(session)
case hidrpc.TypePointerReport:
pointerReport, err := message.PointerReport()
if err != nil {
@ -52,8 +51,13 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
}
}
func onHidMessage(data []byte, session *Session) {
scopedLogger := hidRPCLogger.With().Bytes("data", data).Logger()
func onHidMessage(msg hidQueueMessage, session *Session) {
data := msg.Data
scopedLogger := hidRPCLogger.With().
Str("channel", msg.channel).
Bytes("data", data).
Logger()
scopedLogger.Debug().Msg("HID RPC message received")
if len(data) < 1 {
@ -68,7 +72,9 @@ func onHidMessage(data []byte, session *Session) {
return
}
scopedLogger = scopedLogger.With().Str("descr", message.String()).Logger()
if scopedLogger.GetLevel() <= zerolog.DebugLevel {
scopedLogger = scopedLogger.With().Str("descr", message.String()).Logger()
}
t := time.Now()
@ -85,27 +91,88 @@ func onHidMessage(data []byte, session *Session) {
}
}
func handleHidRPCKeyboardInput(message hidrpc.Message) (*usbgadget.KeysDownState, error) {
// Tunables
// Keep in mind
// macOS default: 15 * 15 = 225ms https://discussions.apple.com/thread/1316947?sortBy=rank
// Linux default: 250ms https://man.archlinux.org/man/kbdrate.8.en
// Windows default: 1s `HKEY_CURRENT_USER\Control Panel\Accessibility\Keyboard Response\AutoRepeatDelay`
const expectedRate = 50 * time.Millisecond // expected keepalive interval
const maxLateness = 50 * time.Millisecond // max jitter we'll tolerate OR jitter budget
const baseExtension = expectedRate + maxLateness // 100ms extension on perfect tick
const maxStaleness = 225 * time.Millisecond // discard ancient packets outright
func handleHidRPCKeypressKeepAlive(session *Session) error {
session.keepAliveJitterLock.Lock()
defer session.keepAliveJitterLock.Unlock()
now := time.Now()
// 1) Staleness guard: ensures packets that arrive far beyond the life of a valid key hold
// (e.g. after a network stall, retransmit burst, or machine sleep) are ignored outright.
// This prevents “zombie” keepalives from reviving a key that should already be released.
if !session.lastTimerResetTime.IsZero() && now.Sub(session.lastTimerResetTime) > maxStaleness {
return nil
}
validTick := true
timerExtension := baseExtension
if !session.lastKeepAliveArrivalTime.IsZero() {
timeSinceLastTick := now.Sub(session.lastKeepAliveArrivalTime)
lateness := timeSinceLastTick - expectedRate
if lateness > 0 {
if lateness <= maxLateness {
// --- Small lateness (within jitterBudget) ---
// This is normal jitter (e.g., Wi-Fi contention).
// We still accept the tick, but *reduce the extension*
// so that the total hold time stays aligned with REAL client side intent.
timerExtension -= lateness
} else {
// --- Large lateness (beyond jitterBudget) ---
// This is likely a retransmit stall or ordering delay.
// We reject the tick entirely and DO NOT extend,
// so the auto-release still fires on time.
validTick = false
}
}
}
if !validTick {
return nil
}
// Only valid ticks update our state and extend the timer.
session.lastKeepAliveArrivalTime = now
session.lastTimerResetTime = now
if gadget != nil {
gadget.DelayAutoReleaseWithDuration(timerExtension)
}
// On a miss: do not advance any state — keeps baseline stable.
return nil
}
func handleHidRPCKeyboardInput(message hidrpc.Message) error {
switch message.Type() {
case hidrpc.TypeKeypressReport:
keypressReport, err := message.KeypressReport()
if err != nil {
logger.Warn().Err(err).Msg("failed to get keypress report")
return nil, err
return err
}
keysDownState, rpcError := rpcKeypressReport(keypressReport.Key, keypressReport.Press)
return &keysDownState, rpcError
return rpcKeypressReport(keypressReport.Key, keypressReport.Press)
case hidrpc.TypeKeyboardReport:
keyboardReport, err := message.KeyboardReport()
if err != nil {
logger.Warn().Err(err).Msg("failed to get keyboard report")
return nil, err
return err
}
keysDownState, rpcError := rpcKeyboardReport(keyboardReport.Modifier, keyboardReport.Keys)
return &keysDownState, rpcError
return rpcKeyboardReport(keyboardReport.Modifier, keyboardReport.Keys)
}
return nil, fmt.Errorf("unknown HID RPC message type: %d", message.Type())
return fmt.Errorf("unknown HID RPC message type: %d", message.Type())
}
func reportHidRPC(params any, session *Session) {
@ -115,7 +182,10 @@ func reportHidRPC(params any, session *Session) {
}
if !session.hidRPCAvailable || session.HidChannel == nil {
logger.Warn().Msg("HID RPC is not available, skipping reportHidRPC")
logger.Warn().
Bool("hidRPCAvailable", session.hidRPCAvailable).
Bool("HidChannel", session.HidChannel != nil).
Msg("HID RPC is not available, skipping reportHidRPC")
return
}
@ -156,7 +226,9 @@ func (s *Session) reportHidRPCKeyboardLedState(state usbgadget.KeyboardState) {
func (s *Session) reportHidRPCKeysDownState(state usbgadget.KeysDownState) {
if !s.hidRPCAvailable {
usbLogger.Debug().Interface("state", state).Msg("reporting keys down state")
writeJSONRPCEvent("keysDownState", state, s)
}
usbLogger.Debug().Interface("state", state).Msg("reporting keys down state, calling reportHidRPC")
reportHidRPC(state, s)
}

View File

@ -10,14 +10,15 @@ import (
type MessageType byte
const (
TypeHandshake MessageType = 0x01
TypeKeyboardReport MessageType = 0x02
TypePointerReport MessageType = 0x03
TypeWheelReport MessageType = 0x04
TypeKeypressReport MessageType = 0x05
TypeMouseReport MessageType = 0x06
TypeKeyboardLedState MessageType = 0x32
TypeKeydownState MessageType = 0x33
TypeHandshake MessageType = 0x01
TypeKeyboardReport MessageType = 0x02
TypePointerReport MessageType = 0x03
TypeWheelReport MessageType = 0x04
TypeKeypressReport MessageType = 0x05
TypeKeypressKeepAliveReport MessageType = 0x09
TypeMouseReport MessageType = 0x06
TypeKeyboardLedState MessageType = 0x32
TypeKeydownState MessageType = 0x33
)
const (

View File

@ -43,6 +43,8 @@ func (m *Message) String() string {
return fmt.Sprintf("MouseReport{Malformed: %v}", m.d)
}
return fmt.Sprintf("MouseReport{DX: %d, DY: %d, Button: %d}", m.d[0], m.d[1], m.d[2])
case TypeKeypressKeepAliveReport:
return "KeypressKeepAliveReport"
default:
return fmt.Sprintf("Unknown{Type: %d, Data: %v}", m.t, m.d)
}

View File

@ -56,13 +56,12 @@ type NetworkConfig struct {
}
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
mode := c.MDNSMode.String
listenOptions := &mdns.MDNSListenOptions{
IPv4: true,
IPv6: true,
IPv4: c.IPv4Mode.String != "disabled",
IPv6: c.IPv6Mode.String != "disabled",
}
switch mode {
switch c.MDNSMode.String {
case "ipv4_only":
listenOptions.IPv6 = false
case "ipv6_only":

View File

@ -48,7 +48,7 @@ type NetworkInterfaceOptions struct {
DefaultHostname string
OnStateChange func(state *NetworkInterfaceState)
OnInitialCheck func(state *NetworkInterfaceState)
OnDhcpLeaseChange func(lease *udhcpc.Lease)
OnDhcpLeaseChange func(lease *udhcpc.Lease, state *NetworkInterfaceState)
OnConfigChange func(config *NetworkConfig)
NetworkConfig *NetworkConfig
}
@ -94,7 +94,7 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
_ = s.updateNtpServersFromLease(lease)
_ = s.setHostnameIfNotSame()
opts.OnDhcpLeaseChange(lease)
opts.OnDhcpLeaseChange(lease, s)
},
})
@ -239,6 +239,10 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
ipv4Addresses = append(ipv4Addresses, addr.IP)
ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String())
} else if addr.IP.To16() != nil {
if s.config.IPv6Mode.String == "disabled" {
continue
}
scopedLogger := s.l.With().Str("ipv6", addr.IP.String()).Logger()
// check if it's a link local address
if addr.IP.IsLinkLocalUnicast() {
@ -287,35 +291,37 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
}
s.ipv4Addresses = ipv4AddressesString
if ipv6LinkLocal != nil {
if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() {
scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger()
if s.ipv6LinkLocal != nil {
scopedLogger.Info().
Str("old_ipv6", s.ipv6LinkLocal.String()).
Msg("IPv6 link local address changed")
} else {
scopedLogger.Info().Msg("IPv6 link local address found")
if s.config.IPv6Mode.String != "disabled" {
if ipv6LinkLocal != nil {
if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() {
scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger()
if s.ipv6LinkLocal != nil {
scopedLogger.Info().
Str("old_ipv6", s.ipv6LinkLocal.String()).
Msg("IPv6 link local address changed")
} else {
scopedLogger.Info().Msg("IPv6 link local address found")
}
s.ipv6LinkLocal = ipv6LinkLocal
changed = true
}
s.ipv6LinkLocal = ipv6LinkLocal
changed = true
}
}
s.ipv6Addresses = ipv6Addresses
s.ipv6Addresses = ipv6Addresses
if len(ipv6Addresses) > 0 {
// compare the addresses to see if there's a change
if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].Address.String() {
scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].Address.String()).Logger()
if s.ipv6Addr != nil {
scopedLogger.Info().
Str("old_ipv6", s.ipv6Addr.String()).
Msg("IPv6 address changed")
} else {
scopedLogger.Info().Msg("IPv6 address found")
if len(ipv6Addresses) > 0 {
// compare the addresses to see if there's a change
if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].Address.String() {
scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].Address.String()).Logger()
if s.ipv6Addr != nil {
scopedLogger.Info().
Str("old_ipv6", s.ipv6Addr.String()).
Msg("IPv6 address changed")
} else {
scopedLogger.Info().Msg("IPv6 address found")
}
s.ipv6Addr = &ipv6Addresses[0].Address
changed = true
}
s.ipv6Addr = &ipv6Addresses[0].Address
changed = true
}
}

View File

@ -65,7 +65,7 @@ func (s *NetworkInterfaceState) IPv6LinkLocalAddress() string {
func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState {
ipv6Addresses := make([]RpcIPv6Address, 0)
if s.ipv6Addresses != nil {
if s.ipv6Addresses != nil && s.config.IPv6Mode.String != "disabled" {
for _, addr := range s.ipv6Addresses {
ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{
Address: addr.Prefix.String(),

View File

@ -9,17 +9,32 @@ import (
"github.com/beevik/ntp"
)
var defaultNTPServers = []string{
var defaultNTPServerIPs = []string{
// These servers are known by static IP and as such don't need DNS lookups
// These are from Google and Cloudflare since if they're down, the internet
// is broken anyway
"162.159.200.1", // time.cloudflare.com IPv4
"162.159.200.123", // time.cloudflare.com IPv4
"2606:4700:f1::1", // time.cloudflare.com IPv6
"2606:4700:f1::123", // time.cloudflare.com IPv6
"216.239.35.0", // time.google.com IPv4
"216.239.35.4", // time.google.com IPv4
"216.239.35.8", // time.google.com IPv4
"216.239.35.12", // time.google.com IPv4
"2001:4860:4806::", // time.google.com IPv6
"2001:4860:4806:4::", // time.google.com IPv6
"2001:4860:4806:8::", // time.google.com IPv6
"2001:4860:4806:c::", // time.google.com IPv6
}
var defaultNTPServerHostnames = []string{
// should use something from https://github.com/jauderho/public-ntp-servers
"time.apple.com",
"time.aws.com",
"time.windows.com",
"time.google.com",
"162.159.200.123", // time.cloudflare.com IPv4
"2606:4700:f1::123", // time.cloudflare.com IPv6
"0.pool.ntp.org",
"1.pool.ntp.org",
"2.pool.ntp.org",
"3.pool.ntp.org",
"time.cloudflare.com",
"pool.ntp.org",
}
func (t *TimeSync) queryNetworkTime(ntpServers []string) (now *time.Time, offset *time.Duration) {

View File

@ -158,6 +158,7 @@ func (t *TimeSync) Sync() error {
var (
now *time.Time
offset *time.Duration
log zerolog.Logger
)
metricTimeSyncCount.Inc()
@ -166,54 +167,54 @@ func (t *TimeSync) Sync() error {
Orders:
for _, mode := range syncMode.Ordering {
log = t.l.With().Str("mode", mode).Logger()
switch mode {
case "ntp_user_provided":
if syncMode.Ntp {
t.l.Info().Msg("using NTP custom servers")
log.Info().Msg("using NTP custom servers")
now, offset = t.queryNetworkTime(t.networkConfig.TimeSyncNTPServers)
if now != nil {
t.l.Info().Str("source", "NTP").Time("now", *now).Msg("time obtained")
break Orders
}
}
case "ntp_dhcp":
if syncMode.Ntp {
t.l.Info().Msg("using NTP servers from DHCP")
log.Info().Msg("using NTP servers from DHCP")
now, offset = t.queryNetworkTime(t.dhcpNtpAddresses)
if now != nil {
t.l.Info().Str("source", "NTP DHCP").Time("now", *now).Msg("time obtained")
break Orders
}
}
case "ntp":
if syncMode.Ntp && syncMode.NtpUseFallback {
t.l.Info().Msg("using NTP fallback")
now, offset = t.queryNetworkTime(defaultNTPServers)
log.Info().Msg("using NTP fallback IPs")
now, offset = t.queryNetworkTime(defaultNTPServerIPs)
if now == nil {
log.Info().Msg("using NTP fallback hostnames")
now, offset = t.queryNetworkTime(defaultNTPServerHostnames)
}
if now != nil {
t.l.Info().Str("source", "NTP fallback").Time("now", *now).Msg("time obtained")
break Orders
}
}
case "http_user_provided":
if syncMode.Http {
t.l.Info().Msg("using HTTP custom URLs")
log.Info().Msg("using HTTP custom URLs")
now = t.queryAllHttpTime(t.networkConfig.TimeSyncHTTPUrls)
if now != nil {
t.l.Info().Str("source", "HTTP").Time("now", *now).Msg("time obtained")
break Orders
}
}
case "http":
if syncMode.Http && syncMode.HttpUseFallback {
t.l.Info().Msg("using HTTP fallback")
log.Info().Msg("using HTTP fallback")
now = t.queryAllHttpTime(defaultHTTPUrls)
if now != nil {
t.l.Info().Str("source", "HTTP fallback").Time("now", *now).Msg("time obtained")
break Orders
}
}
default:
t.l.Warn().Str("mode", mode).Msg("unknown time sync mode, skipping")
log.Warn().Msg("unknown time sync mode, skipping")
}
}
@ -226,6 +227,8 @@ Orders:
now = &newNow
}
log.Info().Time("now", *now).Msg("time obtained")
err := t.setSystemTime(*now)
if err != nil {
return fmt.Errorf("failed to set system time: %w", err)

View File

@ -5,7 +5,11 @@ import (
"context"
"fmt"
"os"
"sync"
"time"
"github.com/rs/xid"
"github.com/rs/zerolog"
)
var keyboardConfig = gadgetConfigItem{
@ -145,32 +149,105 @@ func (u *UsbGadget) GetKeysDownState() KeysDownState {
return u.keysDownState
}
func (u *UsbGadget) updateKeyDownState(state KeysDownState) {
u.log.Trace().Interface("old", u.keysDownState).Interface("new", state).Msg("acquiring keyboardStateLock for updateKeyDownState")
func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) {
u.onKeysDownChange = &f
}
// this is intentional to unlock keyboard state lock before onKeysDownChange callback
{
u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock()
func (u *UsbGadget) SetOnKeepAliveReset(f func()) {
u.onKeepAliveReset = &f
}
if u.keysDownState.Modifier == state.Modifier &&
bytes.Equal(u.keysDownState.Keys, state.Keys) {
return // No change in key down state
}
// DefaultAutoReleaseDuration is the default duration for auto-release of a key.
const DefaultAutoReleaseDuration = 100 * time.Millisecond
u.log.Trace().Interface("old", u.keysDownState).Interface("new", state).Msg("keysDownState updated")
u.keysDownState = state
func (u *UsbGadget) scheduleAutoRelease(key byte) {
u.kbdAutoReleaseLock.Lock()
defer unlockWithLog(&u.kbdAutoReleaseLock, u.log, "autoRelease scheduled")
if u.kbdAutoReleaseTimers[key] != nil {
u.kbdAutoReleaseTimers[key].Stop()
}
if u.onKeysDownChange != nil {
u.log.Trace().Interface("state", state).Msg("calling onKeysDownChange")
(*u.onKeysDownChange)(state)
u.log.Trace().Interface("state", state).Msg("onKeysDownChange called")
duration := u.kbdAutoReleaseTimerExtension
if duration == 0 {
duration = DefaultAutoReleaseDuration
}
u.log.Debug().Dur("duration", duration).Msg("autoRelease scheduled with duration")
u.kbdAutoReleaseTimers[key] = time.AfterFunc(duration, func() {
u.performAutoRelease(key)
})
}
func (u *UsbGadget) cancelAutoRelease(key byte) {
u.kbdAutoReleaseLock.Lock()
defer unlockWithLog(&u.kbdAutoReleaseLock, u.log, "autoRelease cancelled")
if timer := u.kbdAutoReleaseTimers[key]; timer != nil {
timer.Stop()
u.kbdAutoReleaseTimers[key] = nil
delete(u.kbdAutoReleaseTimers, key)
// Reset keep-alive timing when key is released
if u.onKeepAliveReset != nil {
(*u.onKeepAliveReset)()
}
}
}
func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) {
u.onKeysDownChange = &f
func (u *UsbGadget) DelayAutoReleaseWithDuration(resetDuration time.Duration) {
u.kbdAutoReleaseLock.Lock()
defer unlockWithLog(&u.kbdAutoReleaseLock, u.log, "autoRelease delayed")
if u.kbdAutoReleaseTimers == nil {
return
}
u.kbdAutoReleaseTimerExtension = resetDuration
u.log.Debug().Dur("reset_duration", resetDuration).Msg("delaying auto-release with dynamic duration")
for _, timer := range u.kbdAutoReleaseTimers {
if timer != nil {
timer.Reset(resetDuration)
}
}
}
func (u *UsbGadget) performAutoRelease(key byte) {
u.kbdAutoReleaseLock.Lock()
if u.kbdAutoReleaseTimers[key] == nil {
u.log.Warn().Uint8("key", key).Msg("autoRelease timer not found")
u.kbdAutoReleaseLock.Unlock()
return
}
u.kbdAutoReleaseTimers[key].Stop()
u.kbdAutoReleaseTimers[key] = nil
delete(u.kbdAutoReleaseTimers, key)
u.kbdAutoReleaseLock.Unlock()
// Skip if already released
state := u.GetKeysDownState()
alreadyReleased := true
for i := range state.Keys {
if state.Keys[i] == key {
alreadyReleased = false
break
}
}
if alreadyReleased {
return
}
_, err := u.keypressReport(key, false)
if err != nil {
u.log.Warn().Uint8("key", key).Msg("failed to release key")
}
}
func (u *UsbGadget) listenKeyboardEvents() {
@ -242,7 +319,11 @@ func (u *UsbGadget) OpenKeyboardHidFile() error {
return u.openKeyboardHidFile()
}
var keyboardWriteHidFileLock sync.Mutex
func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error {
keyboardWriteHidFileLock.Lock()
defer keyboardWriteHidFileLock.Unlock()
if err := u.openKeyboardHidFile(); err != nil {
return err
}
@ -266,17 +347,29 @@ func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState {
}
}
downState := KeysDownState{
state := KeysDownState{
Modifier: modifier,
Keys: []byte(keys[:]),
}
u.updateKeyDownState(downState)
return downState
u.keyboardStateLock.Lock()
if u.keysDownState.Modifier == state.Modifier &&
bytes.Equal(u.keysDownState.Keys, state.Keys) {
u.keyboardStateLock.Unlock()
return state // No change in key down state
}
u.keysDownState = state
u.keyboardStateLock.Unlock()
if u.onKeysDownChange != nil {
(*u.onKeysDownChange)(state) // this enques to the outgoing hidrpc queue via usb.go → currentSession.enqueueKeysDownState(...)
}
return state
}
func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) (KeysDownState, error) {
u.keyboardLock.Lock()
defer u.keyboardLock.Unlock()
func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) error {
defer u.resetUserInputTime()
if len(keys) > hidKeyBufferSize {
@ -291,7 +384,8 @@ func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) (KeysDownState, e
u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keyboard report to hidg0")
}
return u.UpdateKeysDown(modifier, keys), err
u.UpdateKeysDown(modifier, keys)
return err
}
const (
@ -331,17 +425,23 @@ var KeyCodeToMaskMap = map[byte]byte{
RightSuper: ModifierMaskRightSuper,
}
func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error) {
u.keyboardLock.Lock()
defer u.keyboardLock.Unlock()
func (u *UsbGadget) keypressReport(key byte, press bool) (KeysDownState, error) {
defer u.resetUserInputTime()
l := u.log.With().Uint8("key", key).Bool("press", press).Logger()
if l.GetLevel() <= zerolog.DebugLevel {
requestID := xid.New()
l = l.With().Str("requestID", requestID.String()).Logger()
}
// IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver
// for handling key presses and releases. It ensures that the USB gadget
// behaves similarly to a real USB HID keyboard. This logic is paralleled
// in the client/browser-side code in useKeyboard.ts so make sure to keep
// them in sync.
var state = u.keysDownState
var state = u.GetKeysDownState()
l.Trace().Interface("state", state).Msg("got keys down state")
modifier := state.Modifier
keys := append([]byte(nil), state.Keys...)
@ -381,22 +481,36 @@ func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error)
// If we reach here it means we didn't find an empty slot or the key in the buffer
if overrun {
if press {
u.log.Error().Uint8("key", key).Msg("keyboard buffer overflow, key not added")
l.Error().Msg("keyboard buffer overflow, key not added")
// Fill all key slots with ErrorRollOver (0x01) to indicate overflow
for i := range keys {
keys[i] = hidErrorRollOver
}
} else {
// If we are releasing a key, and we didn't find it in a slot, who cares?
u.log.Warn().Uint8("key", key).Msg("key not found in buffer, nothing to release")
l.Warn().Msg("key not found in buffer, nothing to release")
}
}
}
err := u.keyboardWriteHidFile(modifier, keys)
if err != nil {
u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keypress report to hidg0")
}
return u.UpdateKeysDown(modifier, keys), err
}
func (u *UsbGadget) KeypressReport(key byte, press bool) error {
state, err := u.keypressReport(key, press)
if err != nil {
u.log.Warn().Uint8("key", key).Bool("press", press).Msg("failed to report key")
}
isRolledOver := state.Keys[0] == hidErrorRollOver
if isRolledOver {
u.cancelAutoRelease(key)
} else if press {
u.scheduleAutoRelease(key)
} else {
u.cancelAutoRelease(key)
}
return err
}

View File

@ -68,6 +68,10 @@ type UsbGadget struct {
keyboardState byte // keyboard latched state (NumLock, CapsLock, ScrollLock, Compose, Kana)
keysDownState KeysDownState // keyboard dynamic state (modifier keys and pressed keys)
kbdAutoReleaseLock sync.Mutex
kbdAutoReleaseTimers map[byte]*time.Timer
kbdAutoReleaseTimerExtension time.Duration
keyboardStateLock sync.Mutex
keyboardStateCtx context.Context
keyboardStateCancel context.CancelFunc
@ -85,6 +89,7 @@ type UsbGadget struct {
onKeyboardStateChange *func(state KeyboardState)
onKeysDownChange *func(state KeysDownState)
onKeepAliveReset *func()
log *zerolog.Logger
@ -118,23 +123,25 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
keyboardCtx, keyboardCancel := context.WithCancel(context.Background())
g := &UsbGadget{
name: name,
kvmGadgetPath: path.Join(gadgetPath, name),
configC1Path: path.Join(gadgetPath, name, "configs/c.1"),
configMap: configMap,
customConfig: *config,
configLock: sync.Mutex{},
keyboardLock: sync.Mutex{},
absMouseLock: sync.Mutex{},
relMouseLock: sync.Mutex{},
txLock: sync.Mutex{},
keyboardStateCtx: keyboardCtx,
keyboardStateCancel: keyboardCancel,
keyboardState: 0,
keysDownState: KeysDownState{Modifier: 0, Keys: []byte{0, 0, 0, 0, 0, 0}}, // must be initialized to hidKeyBufferSize (6) zero bytes
enabledDevices: *enabledDevices,
lastUserInput: time.Now(),
log: logger,
name: name,
kvmGadgetPath: path.Join(gadgetPath, name),
configC1Path: path.Join(gadgetPath, name, "configs/c.1"),
configMap: configMap,
customConfig: *config,
configLock: sync.Mutex{},
keyboardLock: sync.Mutex{},
absMouseLock: sync.Mutex{},
relMouseLock: sync.Mutex{},
txLock: sync.Mutex{},
keyboardStateCtx: keyboardCtx,
keyboardStateCancel: keyboardCancel,
keyboardState: 0,
keysDownState: KeysDownState{Modifier: 0, Keys: []byte{0, 0, 0, 0, 0, 0}}, // must be initialized to hidKeyBufferSize (6) zero bytes
kbdAutoReleaseTimers: make(map[byte]*time.Timer),
kbdAutoReleaseTimerExtension: 0,
enabledDevices: *enabledDevices,
lastUserInput: time.Now(),
log: logger,
strictMode: config.strictMode,
@ -149,3 +156,35 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
return g
}
// Close cleans up resources used by the USB gadget
func (u *UsbGadget) Close() error {
// Cancel keyboard state context
if u.keyboardStateCancel != nil {
u.keyboardStateCancel()
}
// Stop auto-release timer
u.kbdAutoReleaseLock.Lock()
for _, timer := range u.kbdAutoReleaseTimers {
timer.Stop()
}
u.kbdAutoReleaseTimers = make(map[byte]*time.Timer)
u.kbdAutoReleaseLock.Unlock()
// Close HID files
if u.keyboardHidFile != nil {
u.keyboardHidFile.Close()
u.keyboardHidFile = nil
}
if u.absMouseHidFile != nil {
u.absMouseHidFile.Close()
u.absMouseHidFile = nil
}
if u.relMouseHidFile != nil {
u.relMouseHidFile.Close()
u.relMouseHidFile = nil
}
return nil
}

View File

@ -9,6 +9,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
@ -120,6 +121,12 @@ func (u *UsbGadget) writeWithTimeout(file *os.File, data []byte) (n int, err err
return
}
u.log.Trace().
Str("file", file.Name()).
Bytes("data", data).
Err(err).
Msg("write failed")
if errors.Is(err, os.ErrDeadlineExceeded) {
u.logWithSuppression(
fmt.Sprintf("writeWithTimeout_%s", file.Name()),
@ -164,3 +171,8 @@ func (u *UsbGadget) resetLogSuppressionCounter(counterName string) {
u.logSuppressionCounter[counterName] = 0
}
}
func unlockWithLog(lock *sync.Mutex, logger *zerolog.Logger, msg string, args ...any) {
logger.Trace().Msgf(msg, args...)
lock.Unlock()
}

71
internal/utils/ssh.go Normal file
View File

@ -0,0 +1,71 @@
package utils
import (
"fmt"
"slices"
"strings"
"golang.org/x/crypto/ssh"
)
// ValidSSHKeyTypes is a list of valid SSH key types
//
// Please make sure that all the types in this list are supported by dropbear
// https://github.com/mkj/dropbear/blob/003c5fcaabc114430d5d14142e95ffdbbd2d19b6/src/signkey.c#L37
//
// ssh-dss is not allowed here as it's insecure
var ValidSSHKeyTypes = []string{
ssh.KeyAlgoRSA,
ssh.KeyAlgoED25519,
ssh.KeyAlgoECDSA256,
ssh.KeyAlgoECDSA384,
ssh.KeyAlgoECDSA521,
}
// ValidateSSHKey validates authorized_keys file content
func ValidateSSHKey(sshKey string) error {
// validate SSH key
var (
hasValidPublicKey = false
lastError = fmt.Errorf("no valid SSH key found")
)
for _, key := range strings.Split(sshKey, "\n") {
key = strings.TrimSpace(key)
// skip empty lines and comments
if key == "" || strings.HasPrefix(key, "#") {
continue
}
parsedPublicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
if err != nil {
lastError = err
continue
}
if parsedPublicKey == nil {
continue
}
parsedType := parsedPublicKey.Type()
textType := strings.Fields(key)[0]
if parsedType != textType {
lastError = fmt.Errorf("parsed SSH key type %s does not match type in text %s", parsedType, textType)
continue
}
if !slices.Contains(ValidSSHKeyTypes, parsedType) {
lastError = fmt.Errorf("invalid SSH key type: %s", parsedType)
continue
}
hasValidPublicKey = true
}
if !hasValidPublicKey {
return lastError
}
return nil
}

208
internal/utils/ssh_test.go Normal file
View File

@ -0,0 +1,208 @@
package utils
import (
"strings"
"testing"
)
func TestValidateSSHKey(t *testing.T) {
tests := []struct {
name string
sshKey string
expectError bool
errorMsg string
}{
{
name: "valid RSA key",
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com",
expectError: false,
},
{
name: "valid ED25519 key",
sshKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSbM8wuD5ab0nHsXaYOqaD3GLLUwmDzSk79Xi/N+H2j test@example.com",
expectError: false,
},
{
name: "valid ECDSA key",
sshKey: "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBAlTkxIo4mXBR+gEX0Q74BpYX4bFFHoX+8Uz7tsob8HvsnMvsEE+BW9h9XrbWX4/4ppL/o6sHbvsqNr9HcyKfdc= test@example.com",
expectError: false,
},
{
name: "multiple valid keys",
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSbM8wuD5ab0nHsXaYOqaD3GLLUwmDzSk79Xi/N+H2j test@example.com",
expectError: false,
},
{
name: "valid key with comment",
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp user@example.com",
expectError: false,
},
{
name: "valid key with options and comment (we don't support options yet)",
sshKey: "command=\"echo hello\" ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp user@example.com",
expectError: true,
},
{
name: "empty string",
sshKey: "",
expectError: true,
errorMsg: "no valid SSH key found",
},
{
name: "whitespace only",
sshKey: " \n\t \n ",
expectError: true,
errorMsg: "no valid SSH key found",
},
{
name: "comment only",
sshKey: "# This is a comment\n# Another comment",
expectError: true,
errorMsg: "no valid SSH key found",
},
{
name: "invalid key format",
sshKey: "not-a-valid-ssh-key",
expectError: true,
},
{
name: "invalid key type",
sshKey: "ssh-dss AAAAB3NzaC1kc3MAAACBAOeB...",
expectError: true,
errorMsg: "invalid SSH key type: ssh-dss",
},
{
name: "unsupported key type",
sshKey: "ssh-rsa-cert-v01@openssh.com AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA...",
expectError: true,
errorMsg: "invalid SSH key type: ssh-rsa-cert-v01@openssh.com",
},
{
name: "malformed key data",
sshKey: "ssh-rsa invalid-base64-data",
expectError: true,
},
{
name: "type mismatch",
sshKey: "ssh-rsa AAAAC3NzaC1lZDI1NTE5AAAAIGomKoH...",
expectError: true,
errorMsg: "parsed SSH key type ssh-ed25519 does not match type in text ssh-rsa",
},
{
name: "mixed valid and invalid keys",
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com\ninvalid-key\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSbM8wuD5ab0nHsXaYOqaD3GLLUwmDzSk79Xi/N+H2j test@example.com",
expectError: false,
},
{
name: "valid key with empty lines and comments",
sshKey: "# Comment line\n\nssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com\n# Another comment\n\t\n",
expectError: false,
},
{
name: "all invalid keys",
sshKey: "invalid-key-1\ninvalid-key-2\nssh-dss AAAAB3NzaC1kc3MAAACBAOeB...",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSSHKey(tt.sshKey)
if tt.expectError {
if err == nil {
t.Errorf("ValidateSSHKey() expected error but got none")
} else if tt.errorMsg != "" && !strings.ContainsAny(err.Error(), tt.errorMsg) {
t.Errorf("ValidateSSHKey() error = %v, expected to contain %v", err, tt.errorMsg)
}
} else {
if err != nil {
t.Errorf("ValidateSSHKey() unexpected error = %v", err)
}
}
})
}
}
func TestValidSSHKeyTypes(t *testing.T) {
expectedTypes := []string{
"ssh-rsa",
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
}
if len(ValidSSHKeyTypes) != len(expectedTypes) {
t.Errorf("ValidSSHKeyTypes length = %d, expected %d", len(ValidSSHKeyTypes), len(expectedTypes))
}
for _, expectedType := range expectedTypes {
found := false
for _, actualType := range ValidSSHKeyTypes {
if actualType == expectedType {
found = true
break
}
}
if !found {
t.Errorf("ValidSSHKeyTypes missing expected type: %s", expectedType)
}
}
}
// TestValidateSSHKeyEdgeCases tests edge cases and boundary conditions
func TestValidateSSHKeyEdgeCases(t *testing.T) {
tests := []struct {
name string
sshKey string
expectError bool
}{
{
name: "key with only type",
sshKey: "ssh-rsa",
expectError: true,
},
{
name: "key with type and empty data",
sshKey: "ssh-rsa ",
expectError: true,
},
{
name: "key with type and whitespace data",
sshKey: "ssh-rsa \t ",
expectError: true,
},
{
name: "key with multiple spaces between type and data",
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com",
expectError: false,
},
{
name: "key with tabs",
sshKey: "\tssh-rsa\tAAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com",
expectError: false,
},
{
name: "very long line",
sshKey: "ssh-rsa " + string(make([]byte, 10000)),
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSSHKey(tt.sshKey)
if tt.expectError {
if err == nil {
t.Errorf("ValidateSSHKey() expected error but got none")
}
} else {
if err != nil {
t.Errorf("ValidateSSHKey() unexpected error = %v", err)
}
}
})
}
}

View File

@ -17,6 +17,7 @@ import (
"go.bug.st/serial"
"github.com/jetkvm/kvm/internal/usbgadget"
"github.com/jetkvm/kvm/internal/utils"
)
type JSONRPCRequest struct {
@ -429,21 +430,27 @@ func rpcGetSSHKeyState() (string, error) {
}
func rpcSetSSHKeyState(sshKey string) error {
if sshKey != "" {
// 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)
}
} else {
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
@ -1059,9 +1066,7 @@ var rpcHandlers = map[string]RPCHandler{
"getNetworkSettings": {Func: rpcGetNetworkSettings},
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
"renewDHCPLease": {Func: rpcRenewDHCPLease},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
"keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}},
"getKeyDownState": {Func: rpcGetKeysDownState},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},

View File

@ -96,16 +96,25 @@ func Main() {
if !config.AutoUpdateEnabled {
return
}
if isTimeSyncNeeded() || !timeSync.IsSyncSuccess() {
logger.Debug().Msg("system time is not synced, will retry in 30 seconds")
time.Sleep(30 * time.Second)
continue
}
if currentSession != nil {
logger.Debug().Msg("skipping update since a session is active")
time.Sleep(1 * time.Minute)
continue
}
includePreRelease := config.IncludePreRelease
err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
if err != nil {
logger.Warn().Err(err).Msg("failed to auto update")
}
time.Sleep(1 * time.Hour)
}
}()

View File

@ -13,10 +13,7 @@ func initMdns() error {
networkState.GetHostname(),
networkState.GetFQDN(),
},
ListenOptions: &mdns.MDNSListenOptions{
IPv4: true,
IPv6: true,
},
ListenOptions: config.NetworkConfig.GetMDNSMode(),
})
if err != nil {
return err

View File

@ -15,7 +15,7 @@ var (
networkState *network.NetworkInterfaceState
)
func networkStateChanged() {
func networkStateChanged(isOnline bool) {
// do not block the main thread
go waitCtrlAndRequestDisplayUpdate(true)
@ -37,6 +37,13 @@ func networkStateChanged() {
networkState.GetFQDN(),
}, true)
}
// if the network is now online, trigger an NTP sync if still needed
if isOnline && timeSync != nil && (isTimeSyncNeeded() || !timeSync.IsSyncSuccess()) {
if err := timeSync.Sync(); err != nil {
logger.Warn().Str("error", err.Error()).Msg("unable to sync time on network state change")
}
}
}
func initNetwork() error {
@ -48,13 +55,13 @@ func initNetwork() error {
NetworkConfig: config.NetworkConfig,
Logger: networkLogger,
OnStateChange: func(state *network.NetworkInterfaceState) {
networkStateChanged()
networkStateChanged(state.IsOnline())
},
OnInitialCheck: func(state *network.NetworkInterfaceState) {
networkStateChanged()
networkStateChanged(state.IsOnline())
},
OnDhcpLeaseChange: func(lease *udhcpc.Lease) {
networkStateChanged()
OnDhcpLeaseChange: func(lease *udhcpc.Lease, state *network.NetworkInterfaceState) {
networkStateChanged(state.IsOnline())
if currentSession == nil {
return
@ -64,7 +71,15 @@ func initNetwork() error {
},
OnConfigChange: func(networkConfig *network.NetworkConfig) {
config.NetworkConfig = networkConfig
networkStateChanged()
networkStateChanged(false)
if mDNS != nil {
_ = mDNS.SetListenOptions(networkConfig.GetMDNSMode())
_ = mDNS.SetLocalNames([]string{
networkState.GetHostname(),
networkState.GetFQDN(),
}, true)
}
},
})

Binary file not shown.

View File

@ -1 +1 @@
6dabd0e657dd099280d9173069687786a4a8c9c25cf7f9e7ce2f940cab67c521
01db2bbcd0bad46c3e21eb3cc5687d15df2153c3d8e2d4665b37acb55f0b5a57

Binary file not shown.

77
scripts/update_netboot_xyz.sh Executable file
View File

@ -0,0 +1,77 @@
#!/usr/bin/env bash
#
# Exit immediately if a command exits with a non-zero status
set -e
C_RST="$(tput sgr0)"
C_ERR="$(tput setaf 1)"
C_OK="$(tput setaf 2)"
C_WARN="$(tput setaf 3)"
C_INFO="$(tput setaf 5)"
msg() { printf '%s%s%s\n' $2 "$1" $C_RST; }
msg_info() { msg "$1" $C_INFO; }
msg_ok() { msg "$1" $C_OK; }
msg_err() { msg "$1" $C_ERR; }
msg_warn() { msg "$1" $C_WARN; }
# Get the latest release information
msg_info "Getting latest release information ..."
LATEST_RELEASE=$(curl -s \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/netbootxyz/netboot.xyz/releases | jq '
[.[] | select(.prerelease == false and .draft == false and .assets != null and (.assets | length > 0))] |
sort_by(.created_at) |
.[-1]')
# Extract version, download URL, and digest
VERSION=$(echo "$LATEST_RELEASE" | jq -r '.tag_name')
ISO_URL=$(echo "$LATEST_RELEASE" | jq -r '.assets[] | select(.name == "netboot.xyz-multiarch.iso") | .browser_download_url')
EXPECTED_CHECKSUM=$(echo "$LATEST_RELEASE" | jq -r '.assets[] | select(.name == "netboot.xyz-multiarch.iso") | .digest' | sed 's/sha256://')
msg_ok "Latest version: $VERSION"
msg_ok "ISO URL: $ISO_URL"
msg_ok "Expected SHA256: $EXPECTED_CHECKSUM"
# Check if we already have the same version
if [ -f "resource/netboot.xyz-multiarch.iso" ]; then
msg_info "Checking current resource file ..."
# First check by checksum (fastest)
CURRENT_CHECKSUM=$(shasum -a 256 resource/netboot.xyz-multiarch.iso | awk '{print $1}')
if [ "$CURRENT_CHECKSUM" = "$EXPECTED_CHECKSUM" ]; then
msg_ok "Resource file is already up to date (version $VERSION). No update needed."
exit 0
else
msg_info "Checksums differ, proceeding with download ..."
fi
fi
# Download ISO file
TMP_ISO=$(mktemp -t netbootxyziso)
msg_info "Downloading ISO file ..."
curl -L -o "$TMP_ISO" "$ISO_URL"
# Verify SHA256 checksum
msg_info "Verifying SHA256 checksum ..."
ACTUAL_CHECKSUM=$(shasum -a 256 "$TMP_ISO" | awk '{print $1}')
if [ "$EXPECTED_CHECKSUM" = "$ACTUAL_CHECKSUM" ]; then
msg_ok "Verified SHA256 checksum."
mv -f "$TMP_ISO" "resource/netboot.xyz-multiarch.iso"
msg_ok "Updated ISO file."
git add "resource/netboot.xyz-multiarch.iso"
git commit -m "chore: update netboot.xyz-multiarch.iso to $VERSION"
msg_ok "Committed changes."
msg_ok "You can now push the changes to the remote repository."
exit 0
else
msg_err "Inconsistent SHA256 checksum."
msg_err "Expected: $EXPECTED_CHECKSUM"
msg_err "Actual: $ACTUAL_CHECKSUM"
exit 1
fi

View File

@ -6,27 +6,34 @@
<!-- These are the fonts used in the app -->
<link
rel="preload"
href="/fonts/CircularXXWeb-Medium.woff2"
href="./public/fonts/CircularXXWeb-Medium.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/CircularXXWeb-Book.woff2"
href="./public/fonts/CircularXXWeb-Book.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/CircularXXWeb-Regular.woff2"
href="./public/fonts/CircularXXWeb-Regular.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="./public/fonts/CircularXXWeb-Black.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<title>JetKVM</title>
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="stylesheet" href="./public/fonts/fonts.css" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
@ -36,23 +43,21 @@
<meta name="theme-color" content="#051946" />
<meta name="description" content="A web-based KVM console for managing remote servers." />
<script>
// Initial theme setup
document.documentElement.classList.toggle(
"dark",
localStorage.theme === "dark" ||
function applyThemeFromPreference() {
// dark theme setup
var darkDesired = localStorage.theme === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches),
);
window.matchMedia("(prefers-color-scheme: dark)").matches)
document.documentElement.classList.toggle("dark", darkDesired)
}
// initial theme application
applyThemeFromPreference();
// Listen for system theme changes
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", ({ matches }) => {
if (!("theme" in localStorage)) {
// Only auto-switch if user hasn't manually set a theme
document.documentElement.classList.toggle("dark", matches);
}
});
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyThemeFromPreference);
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", applyThemeFromPreference);
</script>
</head>
<body

80
ui/package-lock.json generated
View File

@ -68,7 +68,7 @@
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.12",
"typescript": "^5.9.2",
"vite": "^7.1.4",
"vite": "^7.1.5",
"vite-tsconfig-paths": "^5.1.4"
},
"engines": {
@ -1793,6 +1793,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.5",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.4",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.5",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.0",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
@ -6563,13 +6623,13 @@
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
@ -6893,9 +6953,9 @@
}
},
"node_modules/vite": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.4.tgz",
"integrity": "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw==",
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz",
"integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
@ -6903,7 +6963,7 @@
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.14"
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"

View File

@ -79,7 +79,7 @@
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.12",
"typescript": "^5.9.2",
"vite": "^7.1.4",
"vite": "^7.1.5",
"vite-tsconfig-paths": "^5.1.4"
}
}

View File

@ -1,2 +0,0 @@
User-agent: *
Disallow: /

View File

@ -17,7 +17,7 @@ export default function Ipv6NetworkCard({
</h3>
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
{networkState?.dhcp_lease?.ip && (
{networkState?.ipv6_link_local && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Link-local

View File

@ -22,6 +22,39 @@ const USBStateMap: Record<USBStates, string> = {
"not attached": "Disconnected",
suspended: "Low power mode",
};
const StatusCardProps: StatusProps = {
configured: {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 shrink-0",
statusIndicatorClassName: "bg-green-500 border-green-600",
},
attached: {
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
iconClassName: "h-5 w-5 text-blue-500",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
addressed: {
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
iconClassName: "h-5 w-5 text-blue-500",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
"not attached": {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
suspended: {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
statusIndicatorClassName: "bg-green-500 border-green-600",
},
};
export default function USBStateStatus({
state,
@ -30,39 +63,7 @@ export default function USBStateStatus({
state: USBStates;
peerConnectionState?: RTCPeerConnectionState | null;
}) {
const StatusCardProps: StatusProps = {
configured: {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 shrink-0",
statusIndicatorClassName: "bg-green-500 border-green-600",
},
attached: {
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
iconClassName: "h-5 w-5 text-blue-500",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
addressed: {
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
iconClassName: "h-5 w-5 text-blue-500",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
"not attached": {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
suspended: {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
statusIndicatorClassName: "bg-green-500 border-green-600",
},
};
const props = StatusCardProps[state];
if (!props) {
console.warn("Unsupported USB state: ", state);

View File

@ -190,7 +190,7 @@ export default function WebRTCVideo() {
if (!isFullscreenEnabled || !videoElm.current) return;
// per https://wicg.github.io/keyboard-lock/#system-key-press-handler
// If keyboard lock is activated after fullscreen is already in effect, then the user my
// If keyboard lock is activated after fullscreen is already in effect, then the user my
// see multiple messages about how to exit fullscreen. For this reason, we recommend that
// developers call lock() before they enter fullscreen:
await requestKeyboardLock();
@ -237,6 +237,7 @@ export default function WebRTCVideo() {
const keyDownHandler = useCallback(
(e: KeyboardEvent) => {
e.preventDefault();
if (e.repeat) return;
const code = getAdjustedKeyCode(e);
const hidKey = keys[code];

View File

@ -6,6 +6,7 @@ export const HID_RPC_MESSAGE_TYPES = {
PointerReport: 0x03,
WheelReport: 0x04,
KeypressReport: 0x05,
KeypressKeepAliveReport: 0x09,
MouseReport: 0x06,
KeyboardLedState: 0x32,
KeysDownState: 0x33,
@ -278,12 +279,23 @@ export class MouseReportMessage extends RpcMessage {
}
}
export class KeypressKeepAliveMessage extends RpcMessage {
constructor() {
super(HID_RPC_MESSAGE_TYPES.KeypressKeepAliveReport);
}
marshal(): Uint8Array {
return new Uint8Array([this.messageType]);
}
}
export const messageRegistry = {
[HID_RPC_MESSAGE_TYPES.Handshake]: HandshakeMessage,
[HID_RPC_MESSAGE_TYPES.KeysDownState]: KeysDownStateMessage,
[HID_RPC_MESSAGE_TYPES.KeyboardLedState]: KeyboardLedStateMessage,
[HID_RPC_MESSAGE_TYPES.KeyboardReport]: KeyboardReportMessage,
[HID_RPC_MESSAGE_TYPES.KeypressReport]: KeypressReportMessage,
[HID_RPC_MESSAGE_TYPES.KeypressKeepAliveReport]: KeypressKeepAliveMessage,
}
export const unmarshalHidRpcMessage = (data: Uint8Array): RpcMessage | undefined => {

View File

@ -106,11 +106,17 @@ export interface RTCState {
rpcDataChannel: RTCDataChannel | null;
rpcHidProtocolVersion: number | null;
setRpcHidProtocolVersion: (version: number) => void;
setRpcHidProtocolVersion: (version: number | null) => void;
rpcHidChannel: RTCDataChannel | null;
setRpcHidChannel: (channel: RTCDataChannel) => void;
rpcHidUnreliableChannel: RTCDataChannel | null;
setRpcHidUnreliableChannel: (channel: RTCDataChannel) => void;
rpcHidUnreliableNonOrderedChannel: RTCDataChannel | null;
setRpcHidUnreliableNonOrderedChannel: (channel: RTCDataChannel) => void;
peerConnectionState: RTCPeerConnectionState | null;
setPeerConnectionState: (state: RTCPeerConnectionState) => void;
@ -158,11 +164,17 @@ export const useRTCStore = create<RTCState>(set => ({
setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }),
rpcHidProtocolVersion: null,
setRpcHidProtocolVersion: (version: number) => set({ rpcHidProtocolVersion: version }),
setRpcHidProtocolVersion: (version: number | null) => set({ rpcHidProtocolVersion: version }),
rpcHidChannel: null,
setRpcHidChannel: (channel: RTCDataChannel) => set({ rpcHidChannel: channel }),
rpcHidUnreliableChannel: null,
setRpcHidUnreliableChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableChannel: channel }),
rpcHidUnreliableNonOrderedChannel: null,
setRpcHidUnreliableNonOrderedChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableNonOrderedChannel: channel }),
transceiver: null,
setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }),

View File

@ -6,6 +6,7 @@ import {
HID_RPC_VERSION,
HandshakeMessage,
KeyboardReportMessage,
KeypressKeepAliveMessage,
KeypressReportMessage,
MouseReportMessage,
PointerReportMessage,
@ -13,38 +14,93 @@ import {
unmarshalHidRpcMessage,
} from "./hidRpc";
const KEEPALIVE_MESSAGE = new KeypressKeepAliveMessage();
interface sendMessageParams {
ignoreHandshakeState?: boolean;
useUnreliableChannel?: boolean;
requireOrdered?: boolean;
}
export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
const { rpcHidChannel, setRpcHidProtocolVersion, rpcHidProtocolVersion } = useRTCStore();
const {
rpcHidChannel,
rpcHidUnreliableChannel,
rpcHidUnreliableNonOrderedChannel,
setRpcHidProtocolVersion,
rpcHidProtocolVersion,
} = useRTCStore();
const rpcHidReady = useMemo(() => {
return rpcHidChannel?.readyState === "open" && rpcHidProtocolVersion !== null;
}, [rpcHidChannel, rpcHidProtocolVersion]);
const rpcHidUnreliableReady = useMemo(() => {
return (
rpcHidUnreliableChannel?.readyState === "open" && rpcHidProtocolVersion !== null
);
}, [rpcHidUnreliableChannel, rpcHidProtocolVersion]);
const rpcHidUnreliableNonOrderedReady = useMemo(() => {
return (
rpcHidUnreliableNonOrderedChannel?.readyState === "open" &&
rpcHidProtocolVersion !== null
);
}, [rpcHidUnreliableNonOrderedChannel, rpcHidProtocolVersion]);
const rpcHidStatus = useMemo(() => {
if (!rpcHidChannel) return "N/A";
if (rpcHidChannel.readyState !== "open") return rpcHidChannel.readyState;
if (!rpcHidProtocolVersion) return "handshaking";
return `ready (v${rpcHidProtocolVersion})`;
}, [rpcHidChannel, rpcHidProtocolVersion]);
return `ready (v${rpcHidProtocolVersion}${rpcHidUnreliableReady ? "+u" : ""})`;
}, [rpcHidChannel, rpcHidUnreliableReady, rpcHidProtocolVersion]);
const sendMessage = useCallback((message: RpcMessage, ignoreHandshakeState = false) => {
if (rpcHidChannel?.readyState !== "open") return;
if (!rpcHidReady && !ignoreHandshakeState) return;
const sendMessage = useCallback(
(
message: RpcMessage,
{
ignoreHandshakeState,
useUnreliableChannel,
requireOrdered = true,
}: sendMessageParams = {},
) => {
if (rpcHidChannel?.readyState !== "open") return;
if (!rpcHidReady && !ignoreHandshakeState) return;
let data: Uint8Array | undefined;
try {
data = message.marshal();
} catch (e) {
console.error("Failed to send HID RPC message", e);
}
if (!data) return;
let data: Uint8Array | undefined;
try {
data = message.marshal();
} catch (e) {
console.error("Failed to send HID RPC message", e);
}
if (!data) return;
rpcHidChannel?.send(data as unknown as ArrayBuffer);
}, [rpcHidChannel, rpcHidReady]);
if (useUnreliableChannel) {
if (requireOrdered && rpcHidUnreliableReady) {
rpcHidUnreliableChannel?.send(data as unknown as ArrayBuffer);
} else if (!requireOrdered && rpcHidUnreliableNonOrderedReady) {
rpcHidUnreliableNonOrderedChannel?.send(data as unknown as ArrayBuffer);
}
return;
}
rpcHidChannel?.send(data as unknown as ArrayBuffer);
},
[
rpcHidChannel,
rpcHidUnreliableChannel,
rpcHidUnreliableNonOrderedChannel,
rpcHidReady,
rpcHidUnreliableReady,
rpcHidUnreliableNonOrderedReady,
],
);
const reportKeyboardEvent = useCallback(
(keys: number[], modifier: number) => {
sendMessage(new KeyboardReportMessage(keys, modifier));
}, [sendMessage],
},
[sendMessage],
);
const reportKeypressEvent = useCallback(
@ -56,7 +112,9 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
const reportAbsMouseEvent = useCallback(
(x: number, y: number, buttons: number) => {
sendMessage(new PointerReportMessage(x, y, buttons));
sendMessage(new PointerReportMessage(x, y, buttons), {
useUnreliableChannel: true,
});
},
[sendMessage],
);
@ -68,29 +126,36 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
[sendMessage],
);
const reportKeypressKeepAlive = useCallback(() => {
sendMessage(KEEPALIVE_MESSAGE);
}, [sendMessage]);
const sendHandshake = useCallback(() => {
if (rpcHidProtocolVersion) return;
if (!rpcHidChannel) return;
sendMessage(new HandshakeMessage(HID_RPC_VERSION), true);
sendMessage(new HandshakeMessage(HID_RPC_VERSION), { ignoreHandshakeState: true });
}, [rpcHidChannel, rpcHidProtocolVersion, sendMessage]);
const handleHandshake = useCallback((message: HandshakeMessage) => {
if (!message.version) {
console.error("Received handshake message without version", message);
return;
}
const handleHandshake = useCallback(
(message: HandshakeMessage) => {
if (!message.version) {
console.error("Received handshake message without version", message);
return;
}
if (message.version > HID_RPC_VERSION) {
// we assume that the UI is always using the latest version of the HID RPC protocol
// so we can't support this
// TODO: use capabilities to determine rather than version number
console.error("Server is using a newer HID RPC version than the client", message);
return;
}
if (message.version > HID_RPC_VERSION) {
// we assume that the UI is always using the latest version of the HID RPC protocol
// so we can't support this
// TODO: use capabilities to determine rather than version number
console.error("Server is using a newer HID RPC version than the client", message);
return;
}
setRpcHidProtocolVersion(message.version);
}, [setRpcHidProtocolVersion]);
setRpcHidProtocolVersion(message.version);
},
[setRpcHidProtocolVersion],
);
useEffect(() => {
if (!rpcHidChannel) return;
@ -123,26 +188,39 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
onHidRpcMessage?.(message);
};
const openHandler = () => {
console.info("HID RPC channel opened");
sendHandshake();
};
const closeHandler = () => {
console.info("HID RPC channel closed");
setRpcHidProtocolVersion(null);
};
rpcHidChannel.addEventListener("message", messageHandler);
rpcHidChannel.addEventListener("close", closeHandler);
rpcHidChannel.addEventListener("open", openHandler);
return () => {
rpcHidChannel.removeEventListener("message", messageHandler);
rpcHidChannel.removeEventListener("close", closeHandler);
rpcHidChannel.removeEventListener("open", openHandler);
};
},
[
rpcHidChannel,
onHidRpcMessage,
setRpcHidProtocolVersion,
sendHandshake,
handleHandshake,
],
);
}, [
rpcHidChannel,
onHidRpcMessage,
setRpcHidProtocolVersion,
sendHandshake,
handleHandshake,
]);
return {
reportKeyboardEvent,
reportKeypressEvent,
reportAbsMouseEvent,
reportRelMouseEvent,
reportKeypressKeepAlive,
rpcHidProtocolVersion,
rpcHidReady,
rpcHidStatus,

View File

@ -1,6 +1,12 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import { hidErrorRollOver, hidKeyBufferSize, KeysDownState, useHidStore, useRTCStore } from "@/hooks/stores";
import {
hidErrorRollOver,
hidKeyBufferSize,
KeysDownState,
useHidStore,
useRTCStore,
} from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidRpc } from "@/hooks/useHidRpc";
import { KeyboardLedStateMessage, KeysDownStateMessage } from "@/hooks/hidRpc";
@ -11,23 +17,27 @@ export default function useKeyboard() {
const { rpcDataChannel } = useRTCStore();
const { keysDownState, setKeysDownState, setKeyboardLedState } = useHidStore();
// Keepalive timer management
const keepAliveTimerRef = useRef<number | null>(null);
// INTRODUCTION: The earlier version of the JetKVM device shipped with all keyboard state
// being tracked on the browser/client-side. When adding the keyPressReport API to the
// device-side code, we have to still support the situation where the browser/client-side code
// is running on the cloud against a device that has not been updated yet and thus does not
// support the keyPressReport API. In that case, we need to handle the key presses locally
// and send the full state to the device, so it can behave like a real USB HID keyboard.
// This flag indicates whether the keyPressReport API is available on the device which is
// This flag indicates whether the keyPressReport API is available on the device which is
// dynamically set when the device responds to the first key press event or reports its
// keysDownState when queried since the keyPressReport was introduced together with the
// keysDownState when queried since the keyPressReport was introduced together with the
// getKeysDownState API.
// HidRPC is a binary format for exchanging keyboard and mouse events
const {
reportKeyboardEvent: sendKeyboardEventHidRpc,
reportKeypressEvent: sendKeypressEventHidRpc,
reportKeypressKeepAlive: sendKeypressKeepAliveHidRpc,
rpcHidReady,
} = useHidRpc((message) => {
} = useHidRpc(message => {
switch (message.constructor) {
case KeysDownStateMessage:
setKeysDownState((message as KeysDownStateMessage).keysDownState);
@ -48,7 +58,9 @@ export default function useKeyboard() {
async (state: KeysDownState) => {
if (rpcDataChannel?.readyState !== "open" && !rpcHidReady) return;
console.debug(`Send keyboardReport keys: ${state.keys}, modifier: ${state.modifier}`);
console.debug(
`Send keyboardReport keys: ${state.keys}, modifier: ${state.modifier}`,
);
if (rpcHidReady) {
console.debug("Sending keyboard report via HidRPC");
@ -56,42 +68,33 @@ export default function useKeyboard() {
return;
}
send("keyboardReport", { keys: state.keys, modifier: state.modifier }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error(`Failed to send keyboard report ${state}`, resp.error);
}
});
send(
"keyboardReport",
{ keys: state.keys, modifier: state.modifier },
(resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error(`Failed to send keyboard report ${state}`, resp.error);
}
},
);
},
[
rpcDataChannel?.readyState,
rpcHidReady,
send,
sendKeyboardEventHidRpc,
],
[rpcDataChannel?.readyState, rpcHidReady, send, sendKeyboardEventHidRpc],
);
// resetKeyboardState is used to reset the keyboard state to no keys pressed and no modifiers.
// This is useful for macros and when the browser loses focus to ensure that the keyboard state
// is clean.
const resetKeyboardState = useCallback(
async () => {
// Reset the keys buffer to zeros and the modifier state to zero
keysDownState.keys.length = hidKeyBufferSize;
keysDownState.keys.fill(0);
keysDownState.modifier = 0;
sendKeyboardEvent(keysDownState);
}, [keysDownState, sendKeyboardEvent]);
// executeMacro is used to execute a macro consisting of multiple steps.
// Each step can have multiple keys, multiple modifiers and a delay.
// The keys and modifiers are pressed together and held for the delay duration.
// After the delay, the keys and modifiers are released and the next step is executed.
// If a step has no keys or modifiers, it is treated as a delay-only step.
// A small pause is added between steps to ensure that the device can process the events.
const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => {
const executeMacro = async (
steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[],
) => {
for (const [index, step] of steps.entries()) {
const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean);
const modifierMask: number = (step.modifiers || []).map(mod => modifiers[mod]).reduce((acc, val) => acc + val, 0);
const modifierMask: number = (step.modifiers || [])
.map(mod => modifiers[mod])
.reduce((acc, val) => acc + val, 0);
// If the step has keys and/or modifiers, press them and hold for the delay
if (keyValues.length > 0 || modifierMask > 0) {
@ -111,12 +114,60 @@ export default function useKeyboard() {
}
};
const KEEPALIVE_INTERVAL = 50;
const cancelKeepAlive = useCallback(() => {
if (keepAliveTimerRef.current) {
clearInterval(keepAliveTimerRef.current);
keepAliveTimerRef.current = null;
}
}, []);
const scheduleKeepAlive = useCallback(() => {
// Clear existing timer if it exists
if (keepAliveTimerRef.current) {
clearInterval(keepAliveTimerRef.current);
}
keepAliveTimerRef.current = setInterval(() => {
sendKeypressKeepAliveHidRpc();
}, KEEPALIVE_INTERVAL);
}, [sendKeypressKeepAliveHidRpc]);
// resetKeyboardState is used to reset the keyboard state to no keys pressed and no modifiers.
// This is useful for macros and when the browser loses focus to ensure that the keyboard state
// is clean.
const resetKeyboardState = useCallback(async () => {
// Cancel keepalive since we're resetting the keyboard state
cancelKeepAlive();
// Reset the keys buffer to zeros and the modifier state to zero
keysDownState.keys.length = hidKeyBufferSize;
keysDownState.keys.fill(0);
keysDownState.modifier = 0;
sendKeyboardEvent(keysDownState);
}, [keysDownState, sendKeyboardEvent, cancelKeepAlive]);
// handleKeyPress is used to handle a key press or release event.
// This function handle both key press and key release events.
// It checks if the keyPressReport API is available and sends the key press event.
// If the keyPressReport API is not available, it simulates the device-side key
// handling for legacy devices and updates the keysDownState accordingly.
// It then sends the full keyboard state to the device.
const sendKeypress = useCallback(
(key: number, press: boolean) => {
cancelKeepAlive();
sendKeypressEventHidRpc(key, press);
if (press) {
scheduleKeepAlive();
}
},
[sendKeypressEventHidRpc, scheduleKeepAlive, cancelKeepAlive],
);
const handleKeyPress = useCallback(
async (key: number, press: boolean) => {
if (rpcDataChannel?.readyState !== "open" && !rpcHidReady) return;
@ -129,10 +180,14 @@ export default function useKeyboard() {
// Older device version doesn't support this API, so we will switch to local key handling
// In that case we will switch to local key handling and update the keysDownState
// in client/browser-side code using simulateDeviceSideKeyHandlingForLegacyDevices.
sendKeypressEventHidRpc(key, press);
sendKeypress(key, press);
} else {
// if the keyPress api is not available, we need to handle the key locally
const downState = simulateDeviceSideKeyHandlingForLegacyDevices(keysDownState, key, press);
const downState = simulateDeviceSideKeyHandlingForLegacyDevices(
keysDownState,
key,
press,
);
sendKeyboardEvent(downState); // then we send the full state
// if we just sent ErrorRollOver, reset to empty state
@ -147,12 +202,16 @@ export default function useKeyboard() {
resetKeyboardState,
rpcDataChannel?.readyState,
sendKeyboardEvent,
sendKeypressEventHidRpc,
sendKeypress,
],
);
// IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists
function simulateDeviceSideKeyHandlingForLegacyDevices(state: KeysDownState, key: number, press: boolean): KeysDownState {
function simulateDeviceSideKeyHandlingForLegacyDevices(
state: KeysDownState,
key: number,
press: boolean,
): KeysDownState {
// IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver
// for handling key presses and releases. It ensures that the USB gadget
// behaves similarly to a real USB HID keyboard. This logic is paralleled
@ -164,7 +223,7 @@ export default function useKeyboard() {
if (modifierMask !== 0) {
// If the key is a modifier key, we update the keyboardModifier state
// by setting or clearing the corresponding bit in the modifier byte.
// This allows us to track the state of dynamic modifier keys like
// This allows us to track the state of dynamic modifier keys like
// Shift, Control, Alt, and Super.
if (press) {
modifiers |= modifierMask;
@ -181,7 +240,7 @@ export default function useKeyboard() {
// and if we find a zero byte, we can place the key there (if press is true)
if (keys[i] === key || keys[i] === 0) {
if (press) {
keys[i] = key // overwrites the zero byte or the same key if already pressed
keys[i] = key; // overwrites the zero byte or the same key if already pressed
} else {
// we are releasing the key, remove it from the buffer
if (keys[i] !== 0) {
@ -197,18 +256,25 @@ export default function useKeyboard() {
// If we reach here it means we didn't find an empty slot or the key in the buffer
if (overrun) {
if (press) {
console.warn(`keyboard buffer overflow current keys ${keys}, key: ${key} not added`);
console.warn(
`keyboard buffer overflow current keys ${keys}, key: ${key} not added`,
);
// Fill all key slots with ErrorRollOver (0x01) to indicate overflow
keys.length = hidKeyBufferSize;
keys.fill(hidErrorRollOver);
} else {
// If we are releasing a key, and we didn't find it in a slot, who cares?
console.debug(`key ${key} not found in buffer, nothing to release`)
console.debug(`key ${key} not found in buffer, nothing to release`);
}
}
}
return { modifier: modifiers, keys };
}
return { handleKeyPress, resetKeyboardState, executeMacro };
// Cleanup function to cancel keepalive timer
const cleanup = useCallback(() => {
cancelKeepAlive();
}, [cancelKeepAlive]);
return { handleKeyPress, resetKeyboardState, executeMacro, cleanup };
}

View File

@ -166,11 +166,11 @@ export default function SettingsNetworkRoute() {
}, [getNetworkState, getNetworkSettings]);
const handleIpv4ModeChange = (value: IPv4Mode | string) => {
setNetworkSettings({ ...networkSettings, ipv4_mode: value as IPv4Mode });
setNetworkSettingsRemote({ ...networkSettings, ipv4_mode: value as IPv4Mode });
};
const handleIpv6ModeChange = (value: IPv6Mode | string) => {
setNetworkSettings({ ...networkSettings, ipv6_mode: value as IPv6Mode });
setNetworkSettingsRemote({ ...networkSettings, ipv6_mode: value as IPv6Mode });
};
const handleLldpModeChange = (value: LLDPMode | string) => {
@ -419,7 +419,7 @@ export default function SettingsNetworkRoute() {
value={networkSettings.ipv6_mode}
onChange={e => handleIpv6ModeChange(e.target.value)}
options={filterUnknown([
// { value: "disabled", label: "Disabled" },
{ value: "disabled", label: "Disabled" },
{ value: "slaac", label: "SLAAC" },
// { value: "dhcpv6", label: "DHCPv6" },
// { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" },

View File

@ -136,6 +136,8 @@ export default function KvmIdRoute() {
rpcDataChannel,
setTransceiver,
setRpcHidChannel,
setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel,
} = useRTCStore();
const location = useLocation();
@ -488,6 +490,24 @@ export default function KvmIdRoute() {
setRpcHidChannel(rpcHidChannel);
};
const rpcHidUnreliableChannel = pc.createDataChannel("hidrpc-unreliable-ordered", {
ordered: true,
maxRetransmits: 0,
});
rpcHidUnreliableChannel.binaryType = "arraybuffer";
rpcHidUnreliableChannel.onopen = () => {
setRpcHidUnreliableChannel(rpcHidUnreliableChannel);
};
const rpcHidUnreliableNonOrderedChannel = pc.createDataChannel("hidrpc-unreliable-nonordered", {
ordered: false,
maxRetransmits: 0,
});
rpcHidUnreliableNonOrderedChannel.binaryType = "arraybuffer";
rpcHidUnreliableNonOrderedChannel.onopen = () => {
setRpcHidUnreliableNonOrderedChannel(rpcHidUnreliableNonOrderedChannel);
};
setPeerConnection(pc);
}, [
cleanupAndStopReconnecting,
@ -499,6 +519,8 @@ export default function KvmIdRoute() {
setPeerConnectionState,
setRpcDataChannel,
setRpcHidChannel,
setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel,
setTransceiver,
]);

View File

@ -31,20 +31,36 @@ export default defineConfig(({ mode, command }) => {
esbuild: {
pure: ["console.debug"],
},
build: { outDir: isCloud ? "dist" : "../static" },
assetsInclude: ["**/*.woff2"],
build: {
outDir: isCloud ? "dist" : "../static",
rollupOptions: {
output: {
manualChunks: (id) => {
if (id.includes("node_modules")) {
return "vendor";
}
return null;
},
assetFileNames: "assets/immutable/[name]-[hash][extname]",
chunkFileNames: "assets/immutable/[name]-[hash].js",
entryFileNames: "assets/immutable/[name]-[hash].js",
},
},
},
server: {
host: "0.0.0.0",
https: useSSL,
proxy: JETKVM_PROXY_URL
? {
"/me": JETKVM_PROXY_URL,
"/device": JETKVM_PROXY_URL,
"/webrtc": JETKVM_PROXY_URL,
"/auth": JETKVM_PROXY_URL,
"/storage": JETKVM_PROXY_URL,
"/cloud": JETKVM_PROXY_URL,
"/developer": JETKVM_PROXY_URL,
}
"/me": JETKVM_PROXY_URL,
"/device": JETKVM_PROXY_URL,
"/webrtc": JETKVM_PROXY_URL,
"/auth": JETKVM_PROXY_URL,
"/storage": JETKVM_PROXY_URL,
"/cloud": JETKVM_PROXY_URL,
"/developer": JETKVM_PROXY_URL,
}
: undefined,
},
base: onDevice && command === "build" ? "/static" : "/",

12
usb.go
View File

@ -33,7 +33,13 @@ func initUsbGadget() {
gadget.SetOnKeysDownChange(func(state usbgadget.KeysDownState) {
if currentSession != nil {
currentSession.reportHidRPCKeysDownState(state)
currentSession.enqueueKeysDownState(state)
}
})
gadget.SetOnKeepAliveReset(func() {
if currentSession != nil {
currentSession.resetKeepAliveTime()
}
})
@ -43,11 +49,11 @@ func initUsbGadget() {
}
}
func rpcKeyboardReport(modifier byte, keys []byte) (usbgadget.KeysDownState, error) {
func rpcKeyboardReport(modifier byte, keys []byte) error {
return gadget.KeyboardReport(modifier, keys)
}
func rpcKeypressReport(key byte, press bool) (usbgadget.KeysDownState, error) {
func rpcKeypressReport(key byte, press bool) error {
return gadget.KeypressReport(key, press)
}

61
web.go
View File

@ -11,6 +11,7 @@ import (
"net/http"
"net/http/pprof"
"path/filepath"
"slices"
"strings"
"time"
@ -24,6 +25,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/zerolog"
"github.com/vearutop/statigz"
"golang.org/x/crypto/bcrypt"
)
@ -66,6 +68,10 @@ type SetupRequest struct {
Password string `json:"password,omitempty"`
}
var cachableFileExtensions = []string{
".jpg", ".jpeg", ".png", ".svg", ".gif", ".webp", ".ico", ".woff2",
}
func setupRouter() *gin.Engine {
gin.SetMode(gin.ReleaseMode)
gin.DisableConsoleColor()
@ -75,23 +81,47 @@ func setupRouter() *gin.Engine {
return *ginLogger
}),
))
staticFS, _ := fs.Sub(staticFiles, "static")
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
logger.Fatal().Err(err).Msg("failed to get rooted static files subdirectory")
}
staticFileServer := http.StripPrefix("/static", statigz.FileServer(
staticFS.(fs.ReadDirFS),
))
// Add a custom middleware to set cache headers for images
// This is crucial for optimizing the initial welcome screen load time
// By enabling caching, we ensure that pre-loaded images are stored in the browser cache
// This allows for a smoother enter animation and improved user experience on the welcome screen
r.Use(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/static/assets/immutable/") {
c.Header("Cache-Control", "public, max-age=31536000, immutable") // Cache for 1 year
c.Next()
return
}
if strings.HasPrefix(c.Request.URL.Path, "/static/") {
ext := filepath.Ext(c.Request.URL.Path)
if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".webp" {
if slices.Contains(cachableFileExtensions, ext) {
c.Header("Cache-Control", "public, max-age=300") // Cache for 5 minutes
}
}
c.Next()
})
r.StaticFS("/static", http.FS(staticFS))
r.GET("/robots.txt", func(c *gin.Context) {
c.Header("Content-Type", "text/plain")
c.Header("Cache-Control", "public, max-age=31536000, immutable") // Cache for 1 year
c.String(http.StatusOK, "User-agent: *\nDisallow: /")
})
r.Any("/static/*w", func(c *gin.Context) {
staticFileServer.ServeHTTP(c.Writer, c.Request)
})
// Public routes (no authentication required)
r.POST("/auth/login-local", handleLogin)
// We use this to determine if the device is setup
@ -532,14 +562,31 @@ func RunWebServer() {
r := setupRouter()
// Determine the binding address based on the config
bindAddress := ":80" // Default to all interfaces
var bindAddress string
listenPort := 80 // default port
useIPv4 := config.NetworkConfig.IPv4Mode.String != "disabled"
useIPv6 := config.NetworkConfig.IPv6Mode.String != "disabled"
if config.LocalLoopbackOnly {
bindAddress = "localhost:80" // Loopback only (both IPv4 and IPv6)
if useIPv4 && useIPv6 {
bindAddress = fmt.Sprintf("localhost:%d", listenPort)
} else if useIPv4 {
bindAddress = fmt.Sprintf("127.0.0.1:%d", listenPort)
} else if useIPv6 {
bindAddress = fmt.Sprintf("[::1]:%d", listenPort)
}
} else {
if useIPv4 && useIPv6 {
bindAddress = fmt.Sprintf(":%d", listenPort)
} else if useIPv4 {
bindAddress = fmt.Sprintf("0.0.0.0:%d", listenPort)
} else if useIPv6 {
bindAddress = fmt.Sprintf("[::]:%d", listenPort)
}
}
logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server")
err := r.Run(bindAddress)
if err != nil {
if err := r.Run(bindAddress); err != nil {
panic(err)
}
}

141
webrtc.go
View File

@ -7,12 +7,14 @@ import (
"net"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"github.com/gin-gonic/gin"
"github.com/jetkvm/kvm/internal/hidrpc"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/usbgadget"
"github.com/pion/webrtc/v4"
"github.com/rs/zerolog"
)
@ -27,9 +29,26 @@ type Session struct {
rpcQueue chan webrtc.DataChannelMessage
hidRPCAvailable bool
hidQueueLock sync.Mutex
hidQueue []chan webrtc.DataChannelMessage
hidRPCAvailable bool
lastKeepAliveArrivalTime time.Time // Track when last keep-alive packet arrived
lastTimerResetTime time.Time // Track when auto-release timer was last reset
keepAliveJitterLock sync.Mutex // Protect jitter compensation timing state
hidQueueLock sync.Mutex
hidQueue []chan hidQueueMessage
keysDownStateQueue chan usbgadget.KeysDownState
}
func (s *Session) resetKeepAliveTime() {
s.keepAliveJitterLock.Lock()
defer s.keepAliveJitterLock.Unlock()
s.lastKeepAliveArrivalTime = time.Time{} // Reset keep-alive timing tracking
s.lastTimerResetTime = time.Time{} // Reset auto-release timer tracking
}
type hidQueueMessage struct {
webrtc.DataChannelMessage
channel string
}
type SessionConfig struct {
@ -78,16 +97,85 @@ func (s *Session) initQueues() {
s.hidQueueLock.Lock()
defer s.hidQueueLock.Unlock()
s.hidQueue = make([]chan webrtc.DataChannelMessage, 0)
s.hidQueue = make([]chan hidQueueMessage, 0)
for i := 0; i < 4; i++ {
q := make(chan webrtc.DataChannelMessage, 256)
q := make(chan hidQueueMessage, 256)
s.hidQueue = append(s.hidQueue, q)
}
}
func (s *Session) handleQueues(index int) {
for msg := range s.hidQueue[index] {
onHidMessage(msg.Data, s)
onHidMessage(msg, s)
}
}
const keysDownStateQueueSize = 256
func (s *Session) initKeysDownStateQueue() {
// serialise outbound key state reports so unreliable links can't stall input handling
s.keysDownStateQueue = make(chan usbgadget.KeysDownState, keysDownStateQueueSize)
go s.handleKeysDownStateQueue()
}
func (s *Session) handleKeysDownStateQueue() {
for state := range s.keysDownStateQueue {
s.reportHidRPCKeysDownState(state)
}
}
func (s *Session) enqueueKeysDownState(state usbgadget.KeysDownState) {
if s == nil || s.keysDownStateQueue == nil {
return
}
select {
case s.keysDownStateQueue <- state:
default:
hidRPCLogger.Warn().Msg("dropping keys down state update; queue full")
}
}
func getOnHidMessageHandler(session *Session, scopedLogger *zerolog.Logger, channel string) func(msg webrtc.DataChannelMessage) {
return func(msg webrtc.DataChannelMessage) {
l := scopedLogger.With().
Str("channel", channel).
Int("length", len(msg.Data)).
Logger()
// only log data if the log level is debug or lower
if scopedLogger.GetLevel() > zerolog.DebugLevel {
l = l.With().Str("data", string(msg.Data)).Logger()
}
if msg.IsString {
l.Warn().Msg("received string data in HID RPC message handler")
return
}
if len(msg.Data) < 1 {
l.Warn().Msg("received empty data in HID RPC message handler")
return
}
l.Trace().Msg("received data in HID RPC message handler")
// Enqueue to ensure ordered processing
queueIndex := hidrpc.GetQueueIndex(hidrpc.MessageType(msg.Data[0]))
if queueIndex >= len(session.hidQueue) || queueIndex < 0 {
l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue index not found")
queueIndex = 3
}
queue := session.hidQueue[queueIndex]
if queue != nil {
queue <- hidQueueMessage{
DataChannelMessage: msg,
channel: channel,
}
} else {
l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue is nil")
return
}
}
}
@ -133,6 +221,7 @@ func newSession(config SessionConfig) (*Session, error) {
session := &Session{peerConnection: peerConnection}
session.rpcQueue = make(chan webrtc.DataChannelMessage, 256)
session.initQueues()
session.initKeysDownStateQueue()
go func() {
for msg := range session.rpcQueue {
@ -157,40 +246,12 @@ func newSession(config SessionConfig) (*Session, error) {
switch d.Label() {
case "hidrpc":
session.HidChannel = d
d.OnMessage(func(msg webrtc.DataChannelMessage) {
l := scopedLogger.With().Int("length", len(msg.Data)).Logger()
// only log data if the log level is debug or lower
if scopedLogger.GetLevel() > zerolog.DebugLevel {
l = l.With().Str("data", string(msg.Data)).Logger()
}
if msg.IsString {
l.Warn().Msg("received string data in HID RPC message handler")
return
}
if len(msg.Data) < 1 {
l.Warn().Msg("received empty data in HID RPC message handler")
return
}
l.Trace().Msg("received data in HID RPC message handler")
// Enqueue to ensure ordered processing
queueIndex := hidrpc.GetQueueIndex(hidrpc.MessageType(msg.Data[0]))
if queueIndex >= len(session.hidQueue) || queueIndex < 0 {
l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue index not found")
queueIndex = 3
}
queue := session.hidQueue[queueIndex]
if queue != nil {
queue <- msg
} else {
l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue is nil")
return
}
})
d.OnMessage(getOnHidMessageHandler(session, scopedLogger, "hidrpc"))
// we won't send anything over the unreliable channels
case "hidrpc-unreliable-ordered":
d.OnMessage(getOnHidMessageHandler(session, scopedLogger, "hidrpc-unreliable-ordered"))
case "hidrpc-unreliable-nonordered":
d.OnMessage(getOnHidMessageHandler(session, scopedLogger, "hidrpc-unreliable-nonordered"))
case "rpc":
session.RPCChannel = d
d.OnMessage(func(msg webrtc.DataChannelMessage) {