From c8dd84c6b7ca5f07aa48f2ce1208c7925ed203de Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Tue, 9 Sep 2025 07:48:49 -0500 Subject: [PATCH 1/5] fix/Jiggler settings not saving (#786) Ensure the jiggler config loads the defaults so they can be saved. Ensure the file.Sync occurs before acknowledging save. Also fixup the old KeyboardLayout to use en-US not en_US --- config.go | 20 ++++++++++++++++++++ jiggler.go | 14 +++++++++----- ui/src/routes/devices.$id.settings.mouse.tsx | 1 + 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/config.go b/config.go index 1fa56a75..680999a3 100644 --- a/config.go +++ b/config.go @@ -118,6 +118,7 @@ var defaultConfig = &Config{ DisplayMaxBrightness: 64, DisplayDimAfterSec: 120, // 2 minutes DisplayOffAfterSec: 1800, // 30 minutes + JigglerEnabled: false, // This is the "Standard" jiggler option in the UI JigglerConfig: &JigglerConfig{ InactivityLimitSeconds: 60, @@ -205,6 +206,15 @@ func LoadConfig() { loadedConfig.NetworkConfig = defaultConfig.NetworkConfig } + if loadedConfig.JigglerConfig == nil { + loadedConfig.JigglerConfig = defaultConfig.JigglerConfig + } + + // fixup old keyboard layout value + if loadedConfig.KeyboardLayout == "en_US" { + loadedConfig.KeyboardLayout = "en-US" + } + config = &loadedConfig logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel) @@ -221,6 +231,11 @@ func SaveConfig() error { logger.Trace().Str("path", configPath).Msg("Saving config") + // fixup old keyboard layout value + if config.KeyboardLayout == "en_US" { + config.KeyboardLayout = "en-US" + } + file, err := os.Create(configPath) if err != nil { return fmt.Errorf("failed to create config file: %w", err) @@ -233,6 +248,11 @@ func SaveConfig() error { return fmt.Errorf("failed to encode config: %w", err) } + if err := file.Sync(); err != nil { + return fmt.Errorf("failed to wite config: %w", err) + } + + logger.Info().Str("path", configPath).Msg("config saved") return nil } diff --git a/jiggler.go b/jiggler.go index 52882c07..b2463e0a 100644 --- a/jiggler.go +++ b/jiggler.go @@ -17,16 +17,20 @@ type JigglerConfig struct { Timezone string `json:"timezone,omitempty"` } -var jigglerEnabled = false var jobDelta time.Duration = 0 var scheduler gocron.Scheduler = nil -func rpcSetJigglerState(enabled bool) { - jigglerEnabled = enabled +func rpcSetJigglerState(enabled bool) error { + config.JigglerEnabled = enabled + err := SaveConfig() + if err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + return nil } func rpcGetJigglerState() bool { - return jigglerEnabled + return config.JigglerEnabled } func rpcGetTimezones() []string { @@ -118,7 +122,7 @@ func runJigglerCronTab() error { } func runJiggler() { - if jigglerEnabled { + if config.JigglerEnabled { if config.JigglerConfig.JitterPercentage != 0 { jitter := calculateJitterDuration(jobDelta) time.Sleep(jitter) diff --git a/ui/src/routes/devices.$id.settings.mouse.tsx b/ui/src/routes/devices.$id.settings.mouse.tsx index f2b169d9..76b0ae27 100644 --- a/ui/src/routes/devices.$id.settings.mouse.tsx +++ b/ui/src/routes/devices.$id.settings.mouse.tsx @@ -90,6 +90,7 @@ export default function SettingsMouseRoute() { send("getJigglerState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) return; const isEnabled = resp.result as boolean; + console.log("Jiggler is enabled:", isEnabled); // If the jiggler is disabled, set the selected option to "disabled" and nothing else if (!isEnabled) return setSelectedJigglerOption("disabled"); From c8662307116dec787458527fa36f657c05660a4d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Sep 2025 12:07:52 +0200 Subject: [PATCH 2/5] 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] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/package-lock.json | 80 ++++++++++++++++++++++++++++++++++++++------ ui/package.json | 2 +- 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 13c6e99a..1d144b07 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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" diff --git a/ui/package.json b/ui/package.json index 4c988250..f6aef35e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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" } } From 6202e3cafac3f7bab3969e80ed6c4f72b50c62ad Mon Sep 17 00:00:00 2001 From: Aveline <352441+ym@users.noreply.github.com> Date: Thu, 11 Sep 2025 19:17:15 +0200 Subject: [PATCH 3/5] chore: serve pre-compressed static files (#793) --- Makefile | 14 +++++++++++++- go.mod | 1 + go.sum | 2 ++ ui/vite.config.ts | 33 ++++++++++++++++++++++++--------- web.go | 24 ++++++++++++++++++++++-- 5 files changed, 62 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index 0da630af..178e6daf 100644 --- a/Makefile +++ b/Makefile @@ -62,7 +62,19 @@ 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/assets \ + -type f \ + \( -name '*.js' \ + -o -name '*.css' \ + -o -name '*.png' \ + -o -name '*.jpg' \ + -o -name '*.jpeg' \ + -o -name '*.gif' \ + -o -name '*.webp' \ + -o -name '*.woff2' \ + \) \ + -exec sh -c 'gzip -9 -kfv {}' \; dev_release: frontend build_dev @echo "Uploading release..." diff --git a/go.mod b/go.mod index 72e57cd2..962c3a1b 100644 --- a/go.mod +++ b/go.mod @@ -81,6 +81,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 diff --git a/go.sum b/go.sum index 36087a28..e19fa9e6 100644 --- a/go.sum +++ b/go.sum @@ -171,6 +171,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= diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 44eec3a7..e227f67f 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -31,20 +31,35 @@ export default defineConfig(({ mode, command }) => { esbuild: { pure: ["console.debug"], }, - build: { outDir: isCloud ? "dist" : "../static" }, + 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" : "/", diff --git a/web.go b/web.go index 21e17e74..883ebb75 100644 --- a/web.go +++ b/web.go @@ -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,11 @@ type SetupRequest struct { Password string `json:"password,omitempty"` } +var cachableFileExtensions = []string{ + ".jpg", ".jpeg", ".png", ".gif", ".webp", ".woff2", + ".ico", +} + func setupRouter() *gin.Engine { gin.SetMode(gin.ReleaseMode) gin.DisableConsoleColor() @@ -75,23 +82,36 @@ func setupRouter() *gin.Engine { return *ginLogger }), )) + staticFS, _ := fs.Sub(staticFiles, "static") + 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.Any("/static/*w", func(c *gin.Context) { + staticFileServer.ServeHTTP(c.Writer, c.Request) + }) r.POST("/auth/login-local", handleLogin) // We use this to determine if the device is setup From 8d1a66806c5bbea9048e1f9fae09079b6431046f Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Thu, 11 Sep 2025 19:57:35 +0200 Subject: [PATCH 4/5] refactor(ui): Don't fetch KeybardAndMouse Icon on every re-render (#795) --- ui/src/components/USBStateStatus.tsx | 67 ++++++++++++++-------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/ui/src/components/USBStateStatus.tsx b/ui/src/components/USBStateStatus.tsx index 9321a19c..ffe2fce6 100644 --- a/ui/src/components/USBStateStatus.tsx +++ b/ui/src/components/USBStateStatus.tsx @@ -22,6 +22,39 @@ const USBStateMap: Record = { "not attached": "Disconnected", suspended: "Low power mode", }; +const StatusCardProps: StatusProps = { + configured: { + icon: ({ className }) => ( + + ), + iconClassName: "h-5 w-5 shrink-0", + statusIndicatorClassName: "bg-green-500 border-green-600", + }, + attached: { + icon: ({ className }) => , + iconClassName: "h-5 w-5 text-blue-500", + statusIndicatorClassName: "bg-slate-300 border-slate-400", + }, + addressed: { + icon: ({ className }) => , + iconClassName: "h-5 w-5 text-blue-500", + statusIndicatorClassName: "bg-slate-300 border-slate-400", + }, + "not attached": { + icon: ({ className }) => ( + + ), + iconClassName: "h-5 w-5 opacity-50 grayscale filter", + statusIndicatorClassName: "bg-slate-300 border-slate-400", + }, + suspended: { + icon: ({ className }) => ( + + ), + 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 }) => ( - - ), - iconClassName: "h-5 w-5 shrink-0", - statusIndicatorClassName: "bg-green-500 border-green-600", - }, - attached: { - icon: ({ className }) => , - iconClassName: "h-5 w-5 text-blue-500", - statusIndicatorClassName: "bg-slate-300 border-slate-400", - }, - addressed: { - icon: ({ className }) => , - iconClassName: "h-5 w-5 text-blue-500", - statusIndicatorClassName: "bg-slate-300 border-slate-400", - }, - "not attached": { - icon: ({ className }) => ( - - ), - iconClassName: "h-5 w-5 opacity-50 grayscale filter", - statusIndicatorClassName: "bg-slate-300 border-slate-400", - }, - suspended: { - icon: ({ className }) => ( - - ), - 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); From ea068414dccf8d52400164dd1ea8c10d305756ad Mon Sep 17 00:00:00 2001 From: Aveline <352441+ym@users.noreply.github.com> Date: Thu, 11 Sep 2025 23:32:40 +0200 Subject: [PATCH 5/5] feat: validate ssh public key before saving (#794) * feat: validate ssh public key before saving * fix: TestValidSSHKeyTypes --- internal/utils/ssh.go | 71 +++++++++++++ internal/utils/ssh_test.go | 208 +++++++++++++++++++++++++++++++++++++ jsonrpc.go | 29 ++++-- 3 files changed, 297 insertions(+), 11 deletions(-) create mode 100644 internal/utils/ssh.go create mode 100644 internal/utils/ssh_test.go diff --git a/internal/utils/ssh.go b/internal/utils/ssh.go new file mode 100644 index 00000000..e4602ffe --- /dev/null +++ b/internal/utils/ssh.go @@ -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 +} diff --git a/internal/utils/ssh_test.go b/internal/utils/ssh_test.go new file mode 100644 index 00000000..f89cb90b --- /dev/null +++ b/internal/utils/ssh_test.go @@ -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) + } + } + }) + } +} diff --git a/jsonrpc.go b/jsonrpc.go index ff3a4b12..61f28df5 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -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