Skip to content
Draft
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
3 changes: 3 additions & 0 deletions cmd/api/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ import (
"github.com/kernel/hypeman/lib/resources"
"github.com/kernel/hypeman/lib/vm_metrics"
"github.com/kernel/hypeman/lib/volumes"
"sync"
)

// ApiService implements the oapi.StrictServerInterface
type ApiService struct {
desktopSlotsOnce sync.Once
desktopSlots chan struct{} // Bounds active desktop requests/upgraded sessions across instances.
Config *config.Config
ImageManager images.Manager
InstanceManager instances.Manager
Expand Down
142 changes: 142 additions & 0 deletions cmd/api/api/cdp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package api

import (
"context"
"encoding/json"
"errors"
"net"
"net/http"
"net/url"
"strings"
"time"

"github.com/go-chi/chi/v5"
"github.com/kernel/hypeman/lib/desktop"
"github.com/kernel/hypeman/lib/hypervisor"
"github.com/kernel/hypeman/lib/instances"
mw "github.com/kernel/hypeman/lib/middleware"
)

// CDPHandler requires instance-write authentication and resolution in the router.
func (s *ApiService) CDPHandler(w http.ResponseWriter, r *http.Request) {
s.serveDesktop(w, r, newDesktopTransport)
}

func newDesktopTransport(inst *instances.Instance) (*http.Transport, error) {
dialer, err := hypervisor.NewVsockDialer(hypervisor.Type(inst.HypervisorType), inst.VsockSocket, inst.VsockCID)
if err != nil {
return nil, err
}
return &http.Transport{DisableKeepAlives: true, ResponseHeaderTimeout: 20 * time.Second, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return dialer.DialVsock(ctx, int(desktop.AgentPort))
}}, nil
}

func (s *ApiService) serveDesktop(w http.ResponseWriter, r *http.Request, transportFor func(*instances.Instance) (*http.Transport, error)) {
inst := mw.GetResolvedInstance[instances.Instance](r.Context())
if inst == nil {
http.Error(w, "instance not resolved", 500)
return
}
if inst.MacOS == nil || hypervisor.Type(inst.HypervisorType) != hypervisor.TypeVZ || inst.MacOS.DesktopAgentUID == 0 || inst.SkipGuestAgent {
http.Error(w, "instance does not declare an enabled macOS desktop agent", 501)
return
}
if inst.State != instances.StateRunning {
http.Error(w, "instance must be running", 409)
return
}
if s.Config == nil || s.Config.MacOSDesktopOrigin == "" {
http.Error(w, "desktop API origin not configured", 503)
return
}
origin, err := desktop.ParseOrigin(s.Config.MacOSDesktopOrigin)
if err != nil {
http.Error(w, "invalid desktop API origin", 503)
return
}
if origins := r.Header.Values("Origin"); len(origins) > 1 || len(origins) == 1 && !desktop.SameOrigin(origins[0], origin) {
http.Error(w, "cross-origin desktop access rejected", 403)
return
}
selector := chi.URLParam(r, "id")
if selector == "" {
selector = inst.Id
}
prefix := "/instances/" + selector + "/cdp"
if !strings.HasPrefix(r.URL.Path, prefix+"/") || desktop.ValidateBodylessRequest(r) != nil {
http.Error(w, "invalid desktop request", 400)
return
}
path := strings.TrimPrefix(r.URL.Path, prefix)
status := path == "/status" && r.Method == http.MethodGet
start := path == "/start" && r.Method == http.MethodPost
request := r.Clone(r.Context())
request.URL.Path = path
if !status && !start {
if err := desktop.ValidateCDPRequest(request); err != nil {
http.Error(w, "unsupported CDP request", 400)
return
}
}
s.desktopSlotsOnce.Do(func() { s.desktopSlots = make(chan struct{}, desktop.MaxSessions) })
select {
case s.desktopSlots <- struct{}{}:
defer func() { <-s.desktopSlots }()
default:
http.Error(w, "desktop session limit reached", http.StatusTooManyRequests)
return
}
transport, err := transportFor(inst)
if err != nil {
http.Error(w, "desktop transport unavailable", 503)
return
}
defer transport.CloseIdleConnections()
timeout := 3 * time.Second
if start {
timeout = 20 * time.Second
}
ctx, cancel := context.WithTimeout(r.Context(), timeout)
state, err := desktop.Probe(ctx, transport, inst.MacOS.DesktopAgentUID, start)
cancel()
if err != nil {
code, message := probeFailure(err)
http.Error(w, message, code)
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Start maps session-not-ready to 503

Medium Severity

POST /cdp/start treats every failed Probe as 503 agent-incompatible. The guest already returns 409 when the Aqua session is not ready, but Probe only checks for HTTP 200, so a logged-out or non-GUI console is reported as an unavailable desktop agent.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f170546. Configure here.

}
if status || start {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(struct {
desktop.Status
DesktopReady bool `json:"desktop_ready"`
}{state, state.SessionReady()})
return
}
if !state.BrowserReady {
http.Error(w, "selected desktop session or managed browser not ready", 409)
return
}
scheme := "ws"
if origin.Scheme == "https" {
scheme = "wss"
}
base := scheme + "://" + origin.Host + "/instances/" + url.PathEscape(inst.Id) + "/cdp"
proxy, err := desktop.NewCDPProxy(transport, base)
if err != nil {
http.Error(w, "invalid instance CDP configuration", 503)
return
}
proxy.ServeHTTP(w, request)
}

// probeFailure maps a desktop probe error to a response. A guest 409 is a state
// conflict (no GUI session, or a browser launch in progress), not an incompatible agent.
func probeFailure(err error) (int, string) {
var agentErr *desktop.AgentStatusError
if errors.As(err, &agentErr) && agentErr.StatusCode == http.StatusConflict {
return http.StatusConflict, "desktop agent has no GUI session or a browser launch is in progress"
}
return http.StatusServiceUnavailable, "selected desktop agent unavailable or incompatible"
}
159 changes: 159 additions & 0 deletions cmd/api/api/cdp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package api

import (
"context"
"encoding/json"
"errors"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"

"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/kernel/hypeman/cmd/api/config"
"github.com/kernel/hypeman/lib/desktop"
"github.com/kernel/hypeman/lib/images"
"github.com/kernel/hypeman/lib/instances"
mw "github.com/kernel/hypeman/lib/middleware"
"github.com/kernel/hypeman/lib/scopes"
"github.com/stretchr/testify/require"
)

type desktopInstances struct {
instances.Manager
inst *instances.Instance
lookups atomic.Int32
}

func (m *desktopInstances) GetInstance(_ context.Context, name string) (*instances.Instance, error) {
m.lookups.Add(1)
if name != "test" && name != "alias" {
return nil, instances.ErrNotFound
}
return m.inst, nil
}
func desktopInstance() *instances.Instance {
return &instances.Instance{StoredMetadata: instances.StoredMetadata{Id: "test", HypervisorType: "vz", MacOS: &images.MacOSImage{DesktopAgentUID: 501}}, State: instances.StateRunning}
}
func desktopToken(t *testing.T, permission string) string {
t.Helper()
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "test", "exp": time.Now().Add(time.Hour).Unix(), "permissions": []string{permission}}).SignedString([]byte("synthetic-test-secret"))
require.NoError(t, err)
return s
}
func desktopRouter(s *ApiService, factory func(*instances.Instance) (*http.Transport, error)) http.Handler {
r := chi.NewRouter()
sub := r.With(mw.JwtAuth("synthetic-test-secret"), scopes.RequireScope(scopes.InstanceWrite), mw.ResolveResource(s.NewResolvers(), ResolverErrorResponder))
h := func(w http.ResponseWriter, r *http.Request) { s.serveDesktop(w, r, factory) }
sub.Get("/instances/{id}/cdp/*", h)
sub.Post("/instances/{id}/cdp/start", h)
return r
}

func TestDesktopAdmissionBeforeDial(t *testing.T) {
for _, tc := range []struct {
name string
change func(*instances.Instance, *config.Config, *http.Request)
code int
}{
{"missing token", func(_ *instances.Instance, _ *config.Config, r *http.Request) { r.Header.Del("Authorization") }, 401},
{"read-only", func(_ *instances.Instance, _ *config.Config, r *http.Request) {
r.Header.Set("Authorization", "Bearer "+desktopToken(t, string(scopes.InstanceRead)))
}, 403},
{"undeclared", func(i *instances.Instance, _ *config.Config, _ *http.Request) { i.MacOS.DesktopAgentUID = 0 }, 501},
{"disabled", func(i *instances.Instance, _ *config.Config, _ *http.Request) { i.SkipGuestAgent = true }, 501},
{"stopped", func(i *instances.Instance, _ *config.Config, _ *http.Request) { i.State = instances.StateStopped }, 409},
{"unknown instance", func(_ *instances.Instance, _ *config.Config, r *http.Request) {
r.URL.Path = "/instances/other/cdp/json/version"
}, 404},
{"no origin config", func(_ *instances.Instance, c *config.Config, _ *http.Request) { c.MacOSDesktopOrigin = "" }, 503},
{"wrong origin", func(_ *instances.Instance, _ *config.Config, r *http.Request) {
r.Header.Set("Origin", "https://evil.example")
}, 403},
{"multiple origins", func(_ *instances.Instance, _ *config.Config, r *http.Request) {
r.Header.Add("Origin", "https://api.example")
r.Header.Add("Origin", "https://api.example")
}, 403},
{"unsafe discovery", func(_ *instances.Instance, _ *config.Config, r *http.Request) {
r.URL.Path = "/instances/test/cdp/json/new"
}, 400},
{"query", func(_ *instances.Instance, _ *config.Config, r *http.Request) { r.URL.RawQuery = "token=secret" }, 400},
} {
t.Run(tc.name, func(t *testing.T) {
i := desktopInstance()
c := &config.Config{MacOSDesktopOrigin: "https://api.example"}
m := &desktopInstances{inst: i}
s := &ApiService{Config: c, InstanceManager: m}
r := httptest.NewRequest("GET", "http://spoof.example/instances/test/cdp/json/version", nil)
r.Header.Set("Authorization", "Bearer "+desktopToken(t, string(scopes.InstanceWrite)))
tc.change(i, c, r)
w := httptest.NewRecorder()
desktopRouter(s, func(*instances.Instance) (*http.Transport, error) { t.Fatal("dial before admission"); return nil, nil }).ServeHTTP(w, r)
require.Equal(t, tc.code, w.Code, w.Body.String())
if tc.code == 401 || tc.name == "read-only" {
require.Zero(t, m.lookups.Load())
}
})
}
}

func TestDesktopHandshakeAndDiscovery(t *testing.T) {
for _, tc := range []struct {
name string
uid uint32
ready bool
code int
}{{"ready", 501, true, 200}, {"wrong desktop user", 502, true, 503}, {"root desktop user", 0, true, 503}, {"browser not ready", 501, false, 409}} {
t.Run(tc.name, func(t *testing.T) {
var calls atomic.Int32
guest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
require.Empty(t, r.Header.Get("Authorization"))
require.Empty(t, r.Header.Get("Cookie"))
require.Empty(t, r.Header.Get("Forwarded"))
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/status" {
_ = json.NewEncoder(w).Encode(desktop.Status{Version: 1, OS: "darwin", Architecture: "arm64", UID: tc.uid, ConsoleUID: tc.uid, GUISession: true, BrowserManaged: tc.ready, BrowserReady: tc.ready})
return
}
require.Equal(t, "/json/version", r.URL.Path)
_, _ = w.Write([]byte(`{"Browser":"Chrome/test","webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/browser/test-id"}`))
}))
defer guest.Close()
m := &desktopInstances{inst: desktopInstance()}
s := &ApiService{Config: &config.Config{MacOSDesktopOrigin: "https://api.example"}, InstanceManager: m}
factory := func(*instances.Instance) (*http.Transport, error) {
return &http.Transport{DisableKeepAlives: true, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "tcp", strings.TrimPrefix(guest.URL, "http://"))
}}, nil
}
r := httptest.NewRequest("GET", "http://spoof.example/instances/alias/cdp/json/version", nil)
r.Header.Set("Authorization", "Bearer "+desktopToken(t, string(scopes.InstanceWrite)))
r.Header.Set("Cookie", "secret=value")
r.Header.Set("Forwarded", "host=evil.example")
r.Header.Set("Origin", "https://api.example")
w := httptest.NewRecorder()
desktopRouter(s, factory).ServeHTTP(w, r)
require.Equal(t, tc.code, w.Code, w.Body.String())
if tc.code == 200 {
require.Contains(t, w.Body.String(), "wss://api.example/instances/test/cdp/devtools/browser/test-id")
require.Equal(t, int32(2), calls.Load())
} else {
require.Equal(t, int32(1), calls.Load())
}
})
}
}

func TestProbeFailureKeepsGuestConflictStatus(t *testing.T) {
code, _ := probeFailure(&desktop.AgentStatusError{StatusCode: http.StatusConflict})
require.Equal(t, http.StatusConflict, code)
code, _ = probeFailure(&desktop.AgentStatusError{StatusCode: http.StatusInternalServerError})
require.Equal(t, http.StatusServiceUnavailable, code)
code, _ = probeFailure(errors.New("transport down"))
require.Equal(t, http.StatusServiceUnavailable, code)
}
Loading
Loading