From 18f7d8425f49b956d8e7d891c392be6c3cbee06d Mon Sep 17 00:00:00 2001 From: luckfox-eng29 Date: Fri, 15 May 2026 18:39:08 +0800 Subject: [PATCH] feat(api): implement API key generation and management functionality Signed-off-by: luckfox-eng29 --- config.go | 10 ++ jsonrpc.go | 103 +++++++++++++--- main.go | 14 +++ mcp.go | 7 +- .../advanced/AdvancedContent.tsx | 116 ++++++++++++++++++ 5 files changed, 232 insertions(+), 18 deletions(-) diff --git a/config.go b/config.go index f76f8b8..52a4d61 100644 --- a/config.go +++ b/config.go @@ -3,6 +3,8 @@ package kvm import ( "bufio" "bytes" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "io" @@ -497,6 +499,14 @@ func SaveConfig() error { return nil } +func generateAPIKey() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + func ensureConfigLoaded() { if config == nil { LoadConfig() diff --git a/jsonrpc.go b/jsonrpc.go index 585a196..23ef0f4 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -79,15 +79,12 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) { } requestString := string(requestBytes) - scopedLogger := jsonRpcLogger.With(). - Str("data", requestString). - Logger() - scopedLogger.Info().Msg("sending JSONRPC event") + jsonRpcLogger.Trace().Str("event", event).Msg("sending JSONRPC event") err = session.RPCChannel.SendText(requestString) if err != nil { - scopedLogger.Warn().Err(err).Msg("error sending JSONRPC event") + jsonRpcLogger.Warn().Err(err).Str("event", event).Msg("error sending JSONRPC event") return } } @@ -497,6 +494,36 @@ func rpcGetUpdateStatus() (*UpdateStatus, error) { return updateStatus, nil } +type SelfSignatureStatus struct { + AppSignatureAbsent bool `json:"appSignatureAbsent,omitempty"` + AppSignatureInvalid bool `json:"appSignatureInvalid,omitempty"` + AppNoPublicKey bool `json:"appNoPublicKey,omitempty"` +} + +func rpcGetSelfSignatureStatus() (*SelfSignatureStatus, error) { + return getSelfSignatureStatus(), nil +} + +func getSelfSignatureStatus() *SelfSignatureStatus { + status := &SelfSignatureStatus{} + publicKey := getOTAPublicKey() + + appBinPath := "/userdata/picokvm/bin/kvm_app" + appSigPath := appBinPath + ".sig" + + status.AppSignatureAbsent = isSigFileAbsent(appSigPath) + + if !status.AppSignatureAbsent { + if publicKey == nil { + status.AppNoPublicKey = true + } else { + status.AppSignatureInvalid = !verifyLocalFileSignature(appBinPath, appSigPath, publicKey) + } + } + + return status +} + func rpcTryUpdate() error { includePreRelease := config.IncludePreRelease go func() { @@ -1298,6 +1325,30 @@ func rpcSetLocalLoopbackOnly(enabled bool) error { return nil } +func rpcGetApiKey() (string, error) { + return config.APIKey, nil +} + +func rpcSetApiKey(apiKey string) error { + config.APIKey = apiKey + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + return nil +} + +func rpcGenerateApiKey() (string, error) { + key, err := generateAPIKey() + if err != nil { + return "", fmt.Errorf("failed to generate API key: %w", err) + } + config.APIKey = key + if err := SaveConfig(); err != nil { + return "", fmt.Errorf("failed to save config: %w", err) + } + return key, nil +} + type IOSettings struct { IO0Status bool `json:"io0Status"` IO1Status bool `json:"io1Status"` @@ -1530,25 +1581,41 @@ func captureScreenshot(format string) ([]byte, error) { os.Remove(jpegScreenshotPath) - resp, err := CallCtrlAction("jpeg_take_snapshot", nil) + // drain any stale signal before triggering + select { + case <-jpegReadyCh: + default: + } + + _, err := CallCtrlAction("jpeg_take_snapshot", nil) if err != nil { logger.Error().Err(err).Msg("jpeg_take_snapshot failed") return nil, fmt.Errorf("failed to trigger JPEG capture: %w", err) } - logger.Info().Interface("response", resp).Msg("jpeg_take_snapshot response") - // Poll for file with timeout - maxAttempts := 10 - for i := 0; i < maxAttempts; i++ { - if data, err := os.ReadFile(jpegScreenshotPath); err == nil && len(data) > 0 { - logger.Info().Int("size", len(data)).Int("attempts", i+1).Msg("JPEG captured successfully") - return data, nil + // wait for jpeg_ready event from native, fall back to polling on timeout + timeout := time.NewTimer(2 * time.Second) + defer timeout.Stop() + select { + case <-jpegReadyCh: + case <-timeout.C: + logger.Warn().Msg("jpeg_ready event not received within 2s, falling back to polling") + for i := 0; i < 5; i++ { + if data, err := os.ReadFile(jpegScreenshotPath); err == nil && len(data) > 0 { + logger.Info().Int("size", len(data)).Msg("JPEG captured (fallback polling)") + return data, nil + } + time.Sleep(200 * time.Millisecond) } - time.Sleep(200 * time.Millisecond) + return nil, fmt.Errorf("JPEG file not found at %s", jpegScreenshotPath) } - logger.Error().Str("path", jpegScreenshotPath).Msg("JPEG file not found after timeout") - return nil, fmt.Errorf("JPEG file not found at %s", jpegScreenshotPath) + data, err := os.ReadFile(jpegScreenshotPath) + if err != nil || len(data) == 0 { + return nil, fmt.Errorf("JPEG file not readable after jpeg_ready event: %w", err) + } + logger.Info().Int("size", len(data)).Msg("JPEG captured successfully") + return data, nil } var rpcHandlers = map[string]RPCHandler{ @@ -1588,6 +1655,7 @@ var rpcHandlers = map[string]RPCHandler{ "setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}}, "getLocalUpdateStatus": {Func: rpcGetLocalUpdateStatus}, "getUpdateStatus": {Func: rpcGetUpdateStatus}, + "getSelfSignatureStatus": {Func: rpcGetSelfSignatureStatus}, "tryUpdate": {Func: rpcTryUpdate}, "getCustomUpdateBaseURL": {Func: rpcGetCustomUpdateBaseURL}, "setCustomUpdateBaseURL": {Func: rpcSetCustomUpdateBaseURL, Params: []string{"baseURL"}}, @@ -1596,6 +1664,9 @@ var rpcHandlers = map[string]RPCHandler{ "getDevModeState": {Func: rpcGetDevModeState}, "getSSHKeyState": {Func: rpcGetSSHKeyState}, "setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}}, + "getApiKey": {Func: rpcGetApiKey}, + "setApiKey": {Func: rpcSetApiKey, Params: []string{"apiKey"}}, + "generateApiKey": {Func: rpcGenerateApiKey}, "getTLSState": {Func: rpcGetTLSState}, "setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}}, "setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}}, diff --git a/main.go b/main.go index d24bdf6..67ea10e 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,20 @@ func Main() { SyncConfigSD(true) LoadConfig() + if config.APIKey == "" { + key, err := generateAPIKey() + if err != nil { + logger.Warn().Err(err).Msg("failed to generate API key") + } else { + config.APIKey = key + if err := SaveConfig(); err != nil { + logger.Warn().Err(err).Msg("failed to save API key to config") + } else { + logger.Info().Msg("generated new API key") + } + } + } + var cancel context.CancelFunc appCtx, cancel = context.WithCancel(context.Background()) defer cancel() diff --git a/mcp.go b/mcp.go index 4e0e615..4c5cdd4 100644 --- a/mcp.go +++ b/mcp.go @@ -26,9 +26,12 @@ func StartMCP(port int, stdio bool) { // SSE mode addr := fmt.Sprintf(":%d", port) sseServer := server.NewSSEServer(s) - handler := sseServer.SSEHandler() - // Add auth for non-localhost + mux := http.NewServeMux() + mux.Handle("/sse", sseServer.SSEHandler()) + mux.Handle("/message", sseServer.MessageHandler()) + + var handler http.Handler = mux if config.APIKey != "" { handler = withAPIKeyAuth(handler, config.APIKey) } diff --git a/ui/src/layout/components_setting/advanced/AdvancedContent.tsx b/ui/src/layout/components_setting/advanced/AdvancedContent.tsx index 31c9724..77c8bbf 100644 --- a/ui/src/layout/components_setting/advanced/AdvancedContent.tsx +++ b/ui/src/layout/components_setting/advanced/AdvancedContent.tsx @@ -28,6 +28,9 @@ export default function SettingsAdvanced() { const [configContent, setConfigContent] = useState(""); const [isSavingConfig, setIsSavingConfig] = useState(false); const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false); + const [apiKey, setApiKey] = useState(""); + const [apiKeyInput, setApiKeyInput] = useState(""); + const [showApiKeyClearWarning, setShowApiKeyClearWarning] = useState(false); const settings = useSettingsStore(); const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget); @@ -53,6 +56,13 @@ export default function SettingsAdvanced() { if ("error" in resp) return; setLocalLoopbackOnly(resp.result as boolean); }); + + send("getApiKey", {}, resp => { + if ("error" in resp) return; + const key = resp.result as string; + setApiKey(key); + setApiKeyInput(key); + }); }, [send, setDeveloperMode]); const getUsbEmulationState = useCallback(() => { @@ -108,6 +118,54 @@ export default function SettingsAdvanced() { }); }, [send]); + const handleUpdateApiKey = useCallback(() => { + if (apiKeyInput === "") { + setShowApiKeyClearWarning(true); + return; + } + send("setApiKey", { apiKey: apiKeyInput }, resp => { + if ("error" in resp) { + notifications.error( + `Failed to update API key: ${resp.error.data || "Unknown error"}`, + ); + return; + } + setApiKey(apiKeyInput); + notifications.success("API key updated successfully"); + }); + }, [send, apiKeyInput]); + + const handleGenerateApiKey = useCallback(() => { + send("generateApiKey", {}, resp => { + if ("error" in resp) { + notifications.error( + `Failed to generate API key: ${resp.error.data || "Unknown error"}`, + ); + return; + } + const newKey = resp.result as string; + setApiKey(newKey); + setApiKeyInput(newKey); + notifications.success("New API key generated and saved"); + }); + }, [send]); + + const confirmClearApiKey = useCallback(() => { + send("generateApiKey", {}, resp => { + if ("error" in resp) { + notifications.error( + `Failed to generate API key: ${resp.error.data || "Unknown error"}`, + ); + return; + } + const newKey = resp.result as string; + setApiKey(newKey); + setApiKeyInput(newKey); + notifications.success("New API key generated and saved"); + }); + setShowApiKeyClearWarning(false); + }, [send]); + const handleUpdateSSHKey = useCallback(() => { send("setSSHKeyState", { sshKey }, resp => { if ("error" in resp) { @@ -240,6 +298,42 @@ export default function SettingsAdvanced() { )} + {isOnDevice && ( +
+ +
+ setApiKeyInput(e.target.value)} + placeholder={$at("Enter API key or leave empty to auto-generate")} + /> +

+ {$at("Used for authenticating MCP and REST API requests.")} +

+
+ + {$at("Save API Key")} + + + {$at("Generate New")} + +
+
+
+ )} + + { + setShowApiKeyClearWarning(false); + }} + title={$at("Clear API Key?")} + description={ + <> +

+ {$at("Setting the API key to empty will auto-generate a new random key.")} +

+

+ {$at("Make sure to update your clients with the new key after saving.")} +

+ + } + variant="warning" + cancelText={$at("Cancel")} + confirmText={$at("Generate New Key")} + onConfirm={confirmClearApiKey} + /> + {