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
6 changes: 6 additions & 0 deletions .github/workflows/server-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ jobs:
env:
KERNEL_CDPMONITOR_CHROME_E2E: "1"

- name: Run WebAuthn page hook browser tests
run: go test -race ./lib/webauthnhook -run '^TestChromiumPageHook$' -count=1 -v
working-directory: server
env:
KERNEL_WEBAUTHN_CHROME_E2E: "1"

- name: Compute short SHA for images
id: vars
shell: bash
Expand Down
2 changes: 2 additions & 0 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ Configure the server using environment variables:
| `MAX_SIZE_MB` | `500` | Default maximum file size (MB) |
| `OUTPUT_DIR` | `.` | Directory to save recordings |
| `FFMPEG_PATH` | `ffmpeg` | Path to the ffmpeg binary |
| `KERNEL_METRO_API_BASE_URL` | | Metro API base URL; answers the WebAuthn page hook (`PUT /webauthn/hook`) |
| `KERNEL_INSTANCE_JWT` | | Instance credential sent to the metro API and the OTLP relay |

#### Example Configuration

Expand Down
14 changes: 14 additions & 0 deletions server/cmd/api/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/kernel/kernel-images/server/lib/recorder"
"github.com/kernel/kernel-images/server/lib/scaletozero"
"github.com/kernel/kernel-images/server/lib/telemetry"
"github.com/kernel/kernel-images/server/lib/webauthnhook"
"github.com/kernel/kernel-images/server/lib/webmcpclient"
)

Expand Down Expand Up @@ -142,6 +143,10 @@ type ApiService struct {
// decide whether to refuse a context that would set its own proxy.
egressPolicy *egresspolicy.State

// webauthnHook answers pages' WebAuthn calls over its own DevTools
// connection; PUT /webauthn/hook turns it on and off.
webauthnHook *webauthnhook.Hook

lifecycleCtx context.Context
lifecycleCancel context.CancelFunc
}
Expand All @@ -160,6 +165,7 @@ func New(
otlpExport OTLPExporter,
s2Storage S2Storage,
egressPolicy *egresspolicy.State,
webauthn webauthnhook.Config,
) (*ApiService, error) {
switch {
case recordManager == nil:
Expand Down Expand Up @@ -199,6 +205,7 @@ func New(
otlpExport: otlpExport,
s2Storage: s2Storage,
egressPolicy: egressPolicy,
webauthnHook: webauthnhook.New(webauthn, upstreamMgr),
webmcp: webmcpclient.NewManager(upstreamMgr),
browserRepl: newBrowserReplManager(),
lifecycleCtx: ctx,
Expand Down Expand Up @@ -471,6 +478,12 @@ func (s *ApiService) StartNetworkMonitor() error {
return s.cdpMonitor.Start(s.lifecycleCtx)
}

// StartWebAuthnHook reconnects the WebAuthn page hook when a previous run of
// the process left it on.
func (s *ApiService) StartWebAuthnHook() {
s.webauthnHook.Start(s.lifecycleCtx)
}

func (s *ApiService) NetworkMetrics() (resets, completed uint64, up bool) {
snapshot := s.cdpMonitor.NetworkSnapshot()
return snapshot.Resets, snapshot.Completed, snapshot.Up
Expand All @@ -482,6 +495,7 @@ func (s *ApiService) Shutdown(ctx context.Context) error {
s.playwrightExecutors.Shutdown(ctx)

_ = s.webmcp.Close()
s.webauthnHook.Stop()
s.monitorMu.Lock()
s.cdpMonitor.Stop()
s.telemetrySession.Stop()
Expand Down
11 changes: 10 additions & 1 deletion server/cmd/api/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/kernel/kernel-images/server/lib/recorder"
"github.com/kernel/kernel-images/server/lib/scaletozero"
"github.com/kernel/kernel-images/server/lib/telemetry"
"github.com/kernel/kernel-images/server/lib/webauthnhook"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -404,11 +405,19 @@ func testEgressPolicy(t *testing.T) *egresspolicy.State {
return egresspolicy.Load(filepath.Join(t.TempDir(), "egress-policy.json"), slog.New(slog.NewTextHandler(io.Discard, nil)))
}

func testWebAuthn(t *testing.T) webauthnhook.Config {
t.Helper()
return webauthnhook.Config{
StatePath: filepath.Join(t.TempDir(), "webauthn-hook.json"),
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
}

// newSvc constructs an ApiService with a fresh telemetry session and event stream.
func newSvc(t *testing.T, mgr recorder.RecordManager) (*ApiService, error) {
t.Helper()
ts, es := newTelemetrySession(t)
return New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, nil, nil, testEgressPolicy(t))
return New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, nil, nil, testEgressPolicy(t), testWebAuthn(t))
}

func TestApiService_PatchChromiumFlags(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion server/cmd/api/api/display_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func testFFmpegFactory(t *testing.T, tempDir string) recorder.FFmpegRecorderFact
func newTestServiceWithFactory(t *testing.T, mgr recorder.RecordManager, factory recorder.FFmpegRecorderFactory) *ApiService {
t.Helper()
ts, es := newTelemetrySession(t)
svc, err := New(mgr, factory, newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, nil, nil, testEgressPolicy(t))
svc, err := New(mgr, factory, newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, nil, nil, testEgressPolicy(t), testWebAuthn(t))
require.NoError(t, err)
return svc
}
Expand Down
2 changes: 1 addition & 1 deletion server/cmd/api/api/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ func (m *mockRecordManager) StopAll(_ context.Context) error
func newTestService(t *testing.T, mgr recorder.RecordManager) *ApiService {
t.Helper()
ts, es := newTelemetrySession(t)
svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, nil, nil, testEgressPolicy(t))
svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, nil, nil, testEgressPolicy(t), testWebAuthn(t))
require.NoError(t, err)
svc.cdpMonitor = &stubCdpMonitor{}
return svc
Expand Down
22 changes: 22 additions & 0 deletions server/cmd/api/api/webauthn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package api

import (
"context"

"github.com/kernel/kernel-images/server/lib/logger"
oapi "github.com/kernel/kernel-images/server/lib/oapi"
)

// PutWebAuthnHook handles PUT /webauthn/hook.
func (s *ApiService) PutWebAuthnHook(ctx context.Context, req oapi.PutWebAuthnHookRequestObject) (oapi.PutWebAuthnHookResponseObject, error) {
log := logger.FromContext(ctx)
if req.Body == nil {
return oapi.PutWebAuthnHook400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "missing body"}}, nil
}
if err := s.webauthnHook.Set(ctx, req.Body.Enabled); err != nil {
log.Error("failed to apply WebAuthn hook", "err", err, "enabled", req.Body.Enabled)
return oapi.PutWebAuthnHook500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to apply WebAuthn hook"}}, nil
}
log.Info("WebAuthn hook applied", "enabled", req.Body.Enabled)
return oapi.PutWebAuthnHook200JSONResponse{Enabled: req.Body.Enabled}, nil
}
31 changes: 31 additions & 0 deletions server/cmd/api/api/webauthn_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package api

import (
"context"
"testing"

oapi "github.com/kernel/kernel-images/server/lib/oapi"
"github.com/stretchr/testify/require"
)

func TestPutWebAuthnHook(t *testing.T) {
svc, err := newSvc(t, newMockRecordManager())
require.NoError(t, err)

for _, want := range []bool{true, true, false, false} {
resp, err := svc.PutWebAuthnHook(context.Background(), oapi.PutWebAuthnHookRequestObject{
Body: &oapi.WebAuthnHook{Enabled: want},
})
require.NoError(t, err)
require.Equal(t, oapi.PutWebAuthnHook200JSONResponse{Enabled: want}, resp)
}
}

func TestPutWebAuthnHookRejectsMissingBody(t *testing.T) {
svc, err := newSvc(t, newMockRecordManager())
require.NoError(t, err)

resp, err := svc.PutWebAuthnHook(context.Background(), oapi.PutWebAuthnHookRequestObject{})
require.NoError(t, err)
require.IsType(t, oapi.PutWebAuthnHook400JSONResponse{}, resp)
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,61 +8,65 @@ import (
"github.com/kernel/kernel-images/server/lib/forkidentity"
)

// otlpIdentityCacheTTL bounds how often the provider re-reads the applied
// identityCacheTTL bounds how often the provider re-reads the applied
// fork-identity payload from disk. Export requests are frequent; the identity
// changes at most once per fork, so a few seconds of staleness is harmless and
// avoids a filesystem read on every request. Only a payload-sourced identity is
// cached; a boot fallback during the pre-apply window is returned uncached so
// the fresh identity is picked up on the very next request.
const otlpIdentityCacheTTL = 3 * time.Second
const identityCacheTTL = 3 * time.Second

// otlpIdentity is the platform identity stamped onto OTLP export: the relay
// bearer credential plus the resource attributes.
type otlpIdentity struct {
// instanceIdentity is the platform identity this VM presents to Kernel
// services: the instance JWT used as a bearer credential (by the OTLP relay
// and the metro API), the OTLP resource attributes, and the metro API URL.
type instanceIdentity struct {
jwt string
instanceName string
metro string
metroAPIURL string
}

// otlpIdentityProvider resolves the OTLP identity per use.
// instanceIdentityProvider resolves the instance identity per use.
//
// In fork-identity-wait mode the platform delivers a fresh KERNEL_INSTANCE_JWT
// (and INST_NAME/METRO_NAME) in the applied identity payload after
// kernel-images-api has already started, and the process is not restarted, so
// the boot-time env values are stale (empty on an unbound fork). This provider
// reads the identity from the applied payload instead, falling back to the boot
// values. Outside fork mode it returns the boot identity unchanged.
type otlpIdentityProvider struct {
boot otlpIdentity
// (and INST_NAME/METRO_NAME/KERNEL_METRO_API_BASE_URL) in the applied identity payload
// after kernel-images-api has already started, and the process is not
// restarted, so the boot-time env values are stale (empty on an unbound fork).
// This provider reads the identity from the applied payload instead, falling
// back to the boot values. Outside fork mode it returns the boot identity
// unchanged.
type instanceIdentityProvider struct {
boot instanceIdentity
forkEnabled bool

mu sync.Mutex
cached otlpIdentity
cached instanceIdentity
cachedOK bool
cachedAt time.Time
}

func newOTLPIdentityProvider(boot otlpIdentity, log *slog.Logger) *otlpIdentityProvider {
func newInstanceIdentityProvider(boot instanceIdentity, log *slog.Logger) *instanceIdentityProvider {
enabled, err := forkidentity.WaitEnabled()
if err != nil {
// Malformed flag: treat as disabled but surface it, otherwise a fork VM
// would silently keep using the stale boot identity.
log.Warn("fork-identity wait flag invalid; treating as disabled", "err", err)
}
return &otlpIdentityProvider{boot: boot, forkEnabled: enabled}
return &instanceIdentityProvider{boot: boot, forkEnabled: enabled}
}

func (p *otlpIdentityProvider) Token() string { return p.current().jwt }
func (p *otlpIdentityProvider) InstanceName() string { return p.current().instanceName }
func (p *otlpIdentityProvider) Metro() string { return p.current().metro }
func (p *instanceIdentityProvider) Token() string { return p.current().jwt }
func (p *instanceIdentityProvider) InstanceName() string { return p.current().instanceName }
func (p *instanceIdentityProvider) Metro() string { return p.current().metro }
func (p *instanceIdentityProvider) MetroAPIURL() string { return p.current().metroAPIURL }

// current returns the identity, caching only a payload-sourced (or non-fork,
// stable) result so a boot fallback during the pre-apply window is retried on
// the next call rather than pinned for the TTL.
func (p *otlpIdentityProvider) current() otlpIdentity {
func (p *instanceIdentityProvider) current() instanceIdentity {
p.mu.Lock()
defer p.mu.Unlock()
if p.cachedOK && time.Since(p.cachedAt) < otlpIdentityCacheTTL {
if p.cachedOK && time.Since(p.cachedAt) < identityCacheTTL {
return p.cached
}
id, ok := p.resolve()
Expand All @@ -75,7 +79,7 @@ func (p *otlpIdentityProvider) current() otlpIdentity {
// resolve reads the applied fork-identity payload. The bool reports whether the
// result is stable/cacheable: true for the boot identity outside fork mode or a
// successfully read payload, false for a boot fallback while a fork is mid-apply.
func (p *otlpIdentityProvider) resolve() (otlpIdentity, bool) {
func (p *instanceIdentityProvider) resolve() (instanceIdentity, bool) {
if !p.forkEnabled {
return p.boot, true
}
Expand All @@ -89,9 +93,10 @@ func (p *otlpIdentityProvider) resolve() (otlpIdentity, bool) {
if err != nil {
return p.boot, false
}
return otlpIdentity{
return instanceIdentity{
jwt: forkidentity.FirstNonEmpty(payload.Get("kernel_instance_jwt"), p.boot.jwt),
instanceName: forkidentity.FirstNonEmpty(payload.InstanceName(), p.boot.instanceName),
metro: forkidentity.FirstNonEmpty(payload.Get("metro_name"), p.boot.metro),
metroAPIURL: forkidentity.FirstNonEmpty(forkidentity.MetroAPIURL(payload), p.boot.metroAPIURL),
}, true
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ func withForkIdentityFiles(t *testing.T) {
func writeAppliedPayload(t *testing.T, jwt, instanceName, metro string) {
t.Helper()
p := forkidentity.Payload{
"instance_name": instanceName,
"metro_name": metro,
"session_intel_url": "https://intel.example",
"kernel_instance_jwt": jwt,
"instance_name": instanceName,
"metro_name": metro,
"session_intel_url": "https://intel.example",
"kernel_metro_api_base_url": "https://metro.example",
"kernel_instance_jwt": jwt,
}
if err := forkidentity.WritePayload(p); err != nil {
t.Fatalf("write payload: %v", err)
Expand All @@ -38,25 +39,25 @@ func writeAppliedPayload(t *testing.T, jwt, instanceName, metro string) {
}
}

func bootIdentity() otlpIdentity {
return otlpIdentity{jwt: "boot-jwt", instanceName: "boot-inst", metro: "boot-metro"}
func bootIdentity() instanceIdentity {
return instanceIdentity{jwt: "boot-jwt", instanceName: "boot-inst", metro: "boot-metro", metroAPIURL: "https://boot-metro.example"}
}

func TestOTLPIdentityProvider(t *testing.T) {
func TestInstanceIdentityProvider(t *testing.T) {
t.Run("non-fork returns boot identity", func(t *testing.T) {
withForkIdentityFiles(t)
t.Setenv(forkidentity.WaitEnv, "")
writeAppliedPayload(t, "payload-jwt", "payload-inst", "payload-metro") // ignored in non-fork
p := newOTLPIdentityProvider(bootIdentity(), slog.Default())
if p.Token() != "boot-jwt" || p.InstanceName() != "boot-inst" || p.Metro() != "boot-metro" {
t.Errorf("got (%q,%q,%q), want boot values", p.Token(), p.InstanceName(), p.Metro())
p := newInstanceIdentityProvider(bootIdentity(), slog.Default())
if p.Token() != "boot-jwt" || p.InstanceName() != "boot-inst" || p.Metro() != "boot-metro" || p.MetroAPIURL() != "https://boot-metro.example" {
t.Errorf("got (%q,%q,%q,%q), want boot values", p.Token(), p.InstanceName(), p.Metro(), p.MetroAPIURL())
}
})

t.Run("fork before apply falls back to boot", func(t *testing.T) {
withForkIdentityFiles(t)
t.Setenv(forkidentity.WaitEnv, "true")
p := newOTLPIdentityProvider(bootIdentity(), slog.Default())
p := newInstanceIdentityProvider(bootIdentity(), slog.Default())
if p.Token() != "boot-jwt" || p.InstanceName() != "boot-inst" {
t.Errorf("got (%q,%q), want boot fallback", p.Token(), p.InstanceName())
}
Expand All @@ -67,17 +68,17 @@ func TestOTLPIdentityProvider(t *testing.T) {
t.Setenv(forkidentity.WaitEnv, "true")
// Empty boot values mirror an unbound fork; the fresh identity comes from
// the applied payload.
p := newOTLPIdentityProvider(otlpIdentity{}, slog.Default())
p := newInstanceIdentityProvider(instanceIdentity{}, slog.Default())
writeAppliedPayload(t, "fresh-jwt", "fresh-inst", "fresh-metro")
if p.Token() != "fresh-jwt" || p.InstanceName() != "fresh-inst" || p.Metro() != "fresh-metro" {
t.Errorf("got (%q,%q,%q), want fresh payload values", p.Token(), p.InstanceName(), p.Metro())
if p.Token() != "fresh-jwt" || p.InstanceName() != "fresh-inst" || p.Metro() != "fresh-metro" || p.MetroAPIURL() != "https://metro.example" {
t.Errorf("got (%q,%q,%q,%q), want fresh payload values", p.Token(), p.InstanceName(), p.Metro(), p.MetroAPIURL())
}
})

t.Run("boot fallback is not cached; apply is picked up next call", func(t *testing.T) {
withForkIdentityFiles(t)
t.Setenv(forkidentity.WaitEnv, "true")
p := newOTLPIdentityProvider(bootIdentity(), slog.Default())
p := newInstanceIdentityProvider(bootIdentity(), slog.Default())
// Pre-apply: boot fallback, and must not be cached.
if got := p.Token(); got != "boot-jwt" {
t.Fatalf("pre-apply Token() = %q, want boot-jwt", got)
Expand Down
Loading
Loading