diff --git a/web.go b/web.go index baa94ff48..55227f365 100644 --- a/web.go +++ b/web.go @@ -228,6 +228,8 @@ func setupRouter() *gin.Engine { protected.POST("/device/send-wol/:mac-addr", handleSendWOLMagicPacket) protected.GET("/diagnostics", handleDiagnosticsDownload) + + protected.POST("/rpc/:method", handleRPCDispatch) } // Catch-all route for SPA diff --git a/web_rpc.go b/web_rpc.go new file mode 100644 index 000000000..ba4621962 --- /dev/null +++ b/web_rpc.go @@ -0,0 +1,40 @@ +package kvm + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +var httpExposedRPC = map[string]bool{ + "getTLSState": true, + "setTLSState": true, + "getNetworkSettings": true, + "setNetworkSettings": true, + "getDeviceID": true, +} + +func handleRPCDispatch(c *gin.Context) { + method := c.Param("method") + if !httpExposedRPC[method] { + c.JSON(http.StatusNotFound, gin.H{"error": "method not found"}) + return + } + + params := map[string]any{} + if c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(¶ms); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + + scopedLogger := jsonRpcLogger.With().Str("method", method).Logger() + result, err := callRPCHandler(scopedLogger, rpcHandlers[method], params) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} diff --git a/web_test.go b/web_test.go new file mode 100644 index 000000000..bc4deb9c1 --- /dev/null +++ b/web_test.go @@ -0,0 +1,153 @@ +//go:build linux && arm + +package kvm + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +const testAuthToken = "test-rpc-token-abc" + +func setupTestRPCConfig(t *testing.T, cfg Config) { + t.Helper() + orig := config + config = &cfg + t.Cleanup(func() { config = orig }) +} + +func newRPCRequest(t *testing.T, path string, body []byte, withAuth bool) *http.Request { + t.Helper() + var req *http.Request + if len(body) > 0 { + req, _ = http.NewRequest(http.MethodPost, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } else { + req, _ = http.NewRequest(http.MethodPost, path, nil) + } + if withAuth { + req.AddCookie(&http.Cookie{Name: "authToken", Value: testAuthToken}) + } + return req +} + +func TestRPCHTTP_Unauthorized(t *testing.T) { + setupTestRPCConfig(t, Config{LocalAuthToken: testAuthToken}) + r := setupRouter() + + w := httptest.NewRecorder() + r.ServeHTTP(w, newRPCRequest(t, "/rpc/getDeviceID", nil, false)) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected %d, got %d: %s", http.StatusUnauthorized, w.Code, w.Body.String()) + } +} + +func TestRPCHTTP_NonAllowlisted_404(t *testing.T) { + setupTestRPCConfig(t, Config{LocalAuthMode: "noPassword"}) + r := setupRouter() + + w := httptest.NewRecorder() + r.ServeHTTP(w, newRPCRequest(t, "/rpc/reboot", nil, false)) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected %d, got %d: %s", http.StatusNotFound, w.Code, w.Body.String()) + } +} + +func TestRPCHTTP_GetDeviceID_Shape(t *testing.T) { + setupTestRPCConfig(t, Config{LocalAuthMode: "noPassword"}) + r := setupRouter() + + w := httptest.NewRecorder() + r.ServeHTTP(w, newRPCRequest(t, "/rpc/getDeviceID", nil, false)) + + if w.Code != http.StatusOK { + t.Fatalf("expected %d, got %d: %s", http.StatusOK, w.Code, w.Body.String()) + } + var result string + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response as string: %v", err) + } + if result == "" { + t.Fatal("expected non-empty device ID") + } +} + +func TestRPCHTTP_GetTLSState_Shape(t *testing.T) { + setupTestRPCConfig(t, Config{LocalAuthMode: "noPassword"}) + r := setupRouter() + + w := httptest.NewRecorder() + r.ServeHTTP(w, newRPCRequest(t, "/rpc/getTLSState", nil, false)) + + if w.Code != http.StatusOK { + t.Fatalf("expected %d, got %d: %s", http.StatusOK, w.Code, w.Body.String()) + } + var result TLSState + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response as TLSState: %v", err) + } +} + +func TestRPCHTTP_SetTLSState_ValidBody_200(t *testing.T) { + setupTestRPCConfig(t, Config{LocalAuthMode: "noPassword"}) + r := setupRouter() + + body, _ := json.Marshal(map[string]any{"state": map[string]any{"mode": "disabled"}}) + w := httptest.NewRecorder() + r.ServeHTTP(w, newRPCRequest(t, "/rpc/setTLSState", body, false)) + + if w.Code != http.StatusOK { + t.Fatalf("expected %d, got %d: %s", http.StatusOK, w.Code, w.Body.String()) + } +} + +func TestRPCHTTP_SetTLSState_MalformedJSON_400(t *testing.T) { + setupTestRPCConfig(t, Config{LocalAuthMode: "noPassword"}) + r := setupRouter() + + w := httptest.NewRecorder() + r.ServeHTTP(w, newRPCRequest(t, "/rpc/setTLSState", []byte("{invalid"), false)) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected %d, got %d: %s", http.StatusBadRequest, w.Code, w.Body.String()) + } +} + +func TestRPCHTTP_SetTLSState_InvalidMode_500(t *testing.T) { + setupTestRPCConfig(t, Config{LocalAuthMode: "noPassword"}) + r := setupRouter() + + body, _ := json.Marshal(map[string]any{"state": map[string]any{"mode": "invalid_mode"}}) + w := httptest.NewRecorder() + r.ServeHTTP(w, newRPCRequest(t, "/rpc/setTLSState", body, false)) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected %d, got %d: %s", http.StatusInternalServerError, w.Code, w.Body.String()) + } +} + +// TestRPCHTTP_Panic_Returns500 verifies that callRPCHandler's panic recovery +// (jsonrpc.go:526-537) is wired through the HTTP dispatch path. +// It forces a nil pointer dereference by setting certStore to nil while +// the TLS mode is "custom", causing getTLSState to panic on certStore.GetCertificate. +func TestRPCHTTP_Panic_Returns500(t *testing.T) { + setupTestRPCConfig(t, Config{LocalAuthMode: "noPassword", TLSMode: "custom"}) + + origCertStore := certStore + certStore = nil + t.Cleanup(func() { certStore = origCertStore }) + + r := setupRouter() + + w := httptest.NewRecorder() + r.ServeHTTP(w, newRPCRequest(t, "/rpc/getTLSState", nil, false)) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected %d, got %d: %s", http.StatusInternalServerError, w.Code, w.Body.String()) + } +}