Compare commits

..

1 Commits

Author SHA1 Message Date
Aveline 9b78cf9f0b
Merge 1e2cee7060 into c866230711 2025-09-11 12:08:05 +02:00
9 changed files with 56 additions and 393 deletions

View File

@ -62,19 +62,7 @@ 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 && \
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 {}' \;
cd ui && npm ci && npm run build:device
dev_release: frontend build_dev
@echo "Uploading release..."

1
go.mod
View File

@ -81,7 +81,6 @@ 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

2
go.sum
View File

@ -171,8 +171,6 @@ 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=

View File

@ -1,71 +0,0 @@
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
}

View File

@ -1,208 +0,0 @@
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

@ -20,7 +20,6 @@ import (
"github.com/jetkvm/kvm/internal/hidrpc"
"github.com/jetkvm/kvm/internal/usbgadget"
"github.com/jetkvm/kvm/internal/utils"
)
type JSONRPCRequest struct {
@ -433,19 +432,7 @@ func rpcGetSSHKeyState() (string, error) {
}
func rpcSetSSHKeyState(sshKey string) error {
if sshKey == "" {
// Remove SSH key file if empty string is provided
if err := os.Remove(sshKeyFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove SSH key file: %w", err)
}
return nil
}
// Validate SSH key
if err := utils.ValidateSSHKey(sshKey); err != nil {
return err
}
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)
@ -455,6 +442,12 @@ func rpcSetSSHKeyState(sshKey string) error {
if err := os.WriteFile(sshKeyFile, []byte(sshKey), 0600); err != nil {
return fmt.Errorf("failed to write SSH key: %w", err)
}
} else {
// 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
}

View File

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

View File

@ -31,22 +31,7 @@ export default defineConfig(({ mode, command }) => {
esbuild: {
pure: ["console.debug"],
},
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",
},
},
},
build: { outDir: isCloud ? "dist" : "../static" },
server: {
host: "0.0.0.0",
https: useSSL,

24
web.go
View File

@ -11,7 +11,6 @@ import (
"net/http"
"net/http/pprof"
"path/filepath"
"slices"
"strings"
"time"
@ -25,7 +24,6 @@ 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"
)
@ -68,11 +66,6 @@ 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()
@ -82,36 +75,23 @@ 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 slices.Contains(cachableFileExtensions, ext) {
if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".webp" {
c.Header("Cache-Control", "public, max-age=300") // Cache for 5 minutes
}
}
c.Next()
})
r.Any("/static/*w", func(c *gin.Context) {
staticFileServer.ServeHTTP(c.Writer, c.Request)
})
r.StaticFS("/static", http.FS(staticFS))
r.POST("/auth/login-local", handleLogin)
// We use this to determine if the device is setup