Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions web.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions web_rpc.go
Original file line number Diff line number Diff line change
@@ -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(&params); 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)
}
153 changes: 153 additions & 0 deletions web_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading