The screen over HTTP

You can read what a Display shows with a plain HTTP GET: one frame as PNG or JPEG, a clip as H.264 in fragmented MP4, or an MJPEG stream. Nothing is stored. Each capture is taken when you ask for it and streamed to you while it is made. Two processes are involved. display-api is a Deployment in liken-system. It authenticates you, authorizes the request, reads the Display to find its node, and streams the answer back. The capture container in the display-operator pod on that node takes the frames from the compositor and encodes them on the node’s GPU.

At a glance

Item Value
Service https://display-api.liken-system.svc
CA ConfigMap display-api-ca in liken-system
Discovery /v1/display
OpenAPI /v1/display/openapi.json
Shipped ClusterRole display-capture-viewer
Audit record a Captured Event on the Display
Route reference Routes

The audio and media operators have the same kind of API for a Sink, a Source, and a Player. The three APIs share the same paths, the same HTTP behavior, and the same standards, but no code.

Authentication

There are two ways to identify yourself: a client certificate or a Bearer token. The API checks for a certificate first, then for a token, in the same order as the Kubernetes API server.

Client certificate

If your TLS connection presents a client certificate signed by the cluster’s own certificate authority, you are that certificate’s subject. Your user name is the subject’s common name, and your groups are the subject’s organization values. This is exactly how the Kubernetes API server reads a client certificate, so the credentials in your kubeconfig identify you here the same way they identify you to kubectl. The API reads the authority from the ConfigMap extension-apiserver-authentication in kube-system, which is where the API server publishes it, and watches it for changes. A rotated authority takes effect as the API server publishes it, with no restart. A certificate from any other authority ends the TLS handshake.

Bearer token

If the connection has no client certificate, the API looks for a Bearer token (RFC 6750 section 2.1). It sends the token in a TokenReview with the audience display-api, and checks that the answer names that audience. A pod’s ordinary API server token does not have that audience, so it does not work here. If your kubeconfig uses OIDC, you have no client certificate. In that case, mint a ServiceAccount token for the display-api audience, as the recipe under Examples does.

Authorization

After it identifies you, the API sends a SubjectAccessReview for the verb get on the resource displays/screen in the API group display.liken.sh, with the name of the Display. That subresource does not exist in any CRD. It is a string that RBAC rules match, like pods/log. The discovery and OpenAPI documents need authentication but no authorization. The info route needs get on displays. Every route authorizes before it reads anything, so a 403 never tells you whether a name exists.

Grants

The base manifests include a ClusterRole named display-capture-viewer with both rules: displays/screen for the capture routes and displays for the info route. Nothing is bound to it. A cluster owner binds it, for example to a ServiceAccount:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: display-viewer
  namespace: liken-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: display-viewer
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: display-capture-viewer
subjects:
  - kind: ServiceAccount
    name: display-viewer
    namespace: liken-system

The same ClusterRole binds to a person. Use kind: User with the common name from their certificate, or kind: Group with one of its organization values.

A role that grants resources: ["*"] on display.liken.sh already includes displays/screen, and so does cluster-admin. Every subject with one of those may look at every screen.

pods/exec in liken-system is also a capture grant. The display-operator pod shares its process namespace between its containers, so a shell in that pod can reach the capture socket.

Every request that returned bytes writes a Captured Event on the Display. The message names the subject and the aspect, so kubectl describe display tells you who looked at a screen and when.

Routes

Every path has the form /v1/{domain}/[namespaces/{ns}/]{plural}/{name}/{aspect}[.{ext}]. domain is the first label of the CRD’s API group, plural is the CRD’s plural name, and aspect is the thing you capture. A namespaced kind has namespaces/{ns} before its plural. A Display is cluster-scoped, so its paths have no namespace. The domain segment is there so that one ingress can later serve every domain’s API under one host name without a path clash. Until then, each domain is its own Service.

Method Path Response Notes
GET, HEAD /v1/display 200 application/json The discovery document
GET, HEAD /v1/display/openapi.json 200 application/openapi+json OpenAPI 3.1
GET, HEAD /v1/display/displays/{name} 200 application/json Size, scale, refresh, formats, and links. A screen that is down still returns what the Display reports
GET, HEAD /v1/display/displays/{name}/screen 200, negotiated Default image/png
GET, HEAD /v1/display/displays/{name}/screen.png 200 image/png One frame
GET, HEAD /v1/display/displays/{name}/screen.jpg 200 image/jpeg One frame
GET, HEAD /v1/display/displays/{name}/screen.mp4 200 video/mp4; codecs="avc1.640029" H.264 in fragmented MP4, until you hang up or the t= end
GET, HEAD /v1/display/displays/{name}/screen.mjpeg 200 multipart/x-mixed-replace; boundary=ffmpeg One image/jpeg part per frame
OPTIONS any of the above 204, no body Allow: GET, HEAD, OPTIONS

The OpenAPI 3.1 document is generated from the router itself, so it lists every route above. The Routes page renders that document with the parameters, responses, and fields of each route. The media type application/openapi+json is provisional (draft-ietf-httpapi-rest-api-mediatypes) and not yet registered. The copy on this site comes from a development build, so its info.version is dev. A release serves its own version.

Query parameters

The API checks every parameter before it captures anything.

Parameter Applies to Values Default Rejected with 400 when
t every capture route Media Fragments NPT: t=begin,end, t=begin, or t=,end none: a clip or a stream runs until you hang up t=a,b with a >= b; a begin over 60 s (captureBeginMax); an end on a still
xywh every capture route pixel: or percent:, then x,y,w,h pixel:, and the whole screen the origin is at or past an edge of the screen
width every capture route the width in pixels, after the crop none: the region’s own width width together with height; a value larger than the source
height every capture route the height in pixels, after the crop none: the region’s own height height together with width; a value larger than the source
framerate a clip or a stream: screen.mp4, screen.mjpeg frames per second, at most the output’s refresh rate 15 a value above the refresh rate; a still
quality JPEG output: screen.jpg, screen.mjpeg the JPEG quality, 1 to 100 85 PNG and MP4

width= and height= are this API’s own parameters. Either one scales the region down after the crop and keeps the aspect ratio. At quality 85, a 1080p MJPEG frame is about 100 KB, which is 12 Mbit/s at 15 fps.

t= and xywh= follow W3C Media Fragments 1.0 (Recommendation, 25 September 2012), sections 4.2.1 and 4.2.2. They go in the query, not in the URL fragment. Section 3.1 says “a URI query produces a new resource, while a URI fragment provides a secondary resource”, and section 7.4 says that with the query form “media type changes are possible”. A request like screen.png?t=5&xywh=... is exactly that. The parser follows section 5.1.1: it splits on & and = first and percent-decodes second, so t=10%2C20 and t=npt%3a10 mean the same as their literal forms.

Media Fragments puts the zero of t= at the start of the source media. A live screen has no start, so this API puts the zero at the instant the capture container accepts the request. The container sends the response headers at once, starts its encoder at once, and discards frames until the begin time on its own clock. t=5,7 discards five seconds and then records two. t=,10 records ten seconds from now. t=5 on a still waits five seconds and takes one frame.

Media Fragments tells a user agent to ignore an invalid, unknown, or non-existent dimension. This API returns 400 instead. A query produces a new resource, so a client that asked for a region must not silently get the whole screen. A repeated dimension and an unknown key are 400 as well. A region that runs off an edge is clipped to the screen (section 6.1.2), not refused. With percent:, the origin rounds down and the size rounds up, and then the width and height round up to an even number for the encoder. pixel: counts the frame’s own physical pixels. If you work in logical pixels, multiply by the scale that the info document reports.

Content negotiation

An extension names one fixed representation. If your Accept header excludes that representation, you get a 406. RFC 9110 section 12.5.1 lets a server send 406 or ignore Accept, and this API tells you when the two things you said contradict each other. Without an extension, the API negotiates on Accept with q-values, per section 12.5.1. No Accept header means any type is acceptable, so you get the default, image/png. Ties and wildcards resolve in this order: image/png, image/jpeg, video/mp4, multipart/x-mixed-replace.

Request Response
GET screen 200 image/png, Content-Location: /v1/display/displays/{name}/screen.png
GET screen, Accept: image/jpeg 200 image/jpeg
GET screen, Accept: image/* 200 image/png, the first image type in the order
GET screen, Accept: video/mp4, image/png;q=0.5 200 video/mp4, a stream
GET screen, Accept: image/png;q=0, */* 200 image/jpeg
GET screen, Accept: audio/wav 406, and acceptable lists the four types
GET screen.png, Accept: image/* 200 image/png
GET screen.png, Accept: video/mp4 406, and acceptable lists screen.png and its three siblings

Response headers

Every response has these headers:

Header Value Standard
Content-Type the media type of the body RFC 9110 section 8.3
Date the time of the response RFC 9110 section 6.6.1
Vary Accept RFC 9110 section 12.5.5
Link </v1/display/openapi.json>; rel="service-desc" RFC 8288, RFC 8631
Link <https://liken.sh/display/docs/reference/api/>; rel="service-doc" RFC 8288, RFC 8631
Link on a route about one Display: <https://kubernetes.default.svc/apis/display.liken.sh/v1alpha1/displays/{name}>; rel="describedby" RFC 8288 section 3.1

Vary: Accept is also on the extension routes, where Accept only decides between 200 and 406. Section 12.5.5 gives a second reason for Vary: to say that the response was subject to negotiation. The describedby URL is absolute because RFC 8288 section 3.1 resolves a relative reference against the API’s own origin, which is the wrong server.

A document (discovery, OpenAPI, or the info route) has an ETag and Cache-Control: no-cache. A capture has these headers instead:

Header Value Standard
Cache-Control no-store RFC 9111 section 5.2.2.5
Content-Disposition inline; filename="{name}-{time}.{ext}" RFC 6266 section 4
Accept-Ranges none RFC 9110 section 14.3
Transfer-Encoding chunked, on HTTP/1.1 RFC 9112 section 7.1
Content-Location on screen only: the absolute path of the extension route that was served RFC 9110 section 8.7
Link on screen only: one rel="alternate"; type="image/png" and so on, per extension route RFC 8288

Content-Location is only sent on the negotiated route. Section 8.7 calls it “a more specific identifier for the selected representation”. The same section says a GET on that URL returns the same representation. That is not true for a live capture, and it is also why Accept-Ranges is none. There is no Content-Length, because RFC 9112 section 6.2 forbids one next to Transfer-Encoding. A still is streamed the same way as a clip. On HTTP/2 (RFC 9113), the protocol frames the body itself.

The time in filename is RFC 3339 UTC with the colons replaced by hyphens, for example HDMI-A-1-2026-09-16T21-02-16Z.png. A colon is not a legal file name character on every system a browser saves to. This is the only place the API writes a time in a non-standard form.

HEAD takes no frame and does not call the capture container. A HEAD on an error returns the status and the headers with no body (RFC 9110 section 15.5).

Errors

Every error is an application/problem+json document (RFC 9457) with type, title, status, detail, and instance. instance is the request path, then #, then the request id. The API’s log line for the request has the same id, so you can find one from the other. detail quotes the source of the error: the compositor’s refusal, the TokenReview error, the Display condition message, or the encoder’s last lines.

Status When Extra header Standard
400 a query the grammar rejects, or any condition the query parameter table above names RFC 9110 section 15.5.1
401 no client certificate and no token: WWW-Authenticate: Bearer realm="display-api". A token the TokenReview refuses: the same header plus error="invalid_token" and an error_description with the review’s own words as stated section 15.5.2, RFC 6750 section 3
403 the SubjectAccessReview said no WWW-Authenticate: Bearer realm="display-api", error="insufficient_scope", scope="displays/screen" section 15.5.4, RFC 6750 section 3
404 no Display has that name section 15.5.5
405 a method other than GET, HEAD, or OPTIONS Allow: GET, HEAD, OPTIONS section 15.5.6
406 Accept excludes everything the route can serve section 15.5.7
409 the Display has no panel on its connector: its Connected condition is False. A retry does not help until the monitor is connected section 15.5.10
500 the compositor denied the capture, and a retry does not help; or the encoder produced no picture section 15.6.1
502 the capture container answered with something that is not HTTP, or not a problem document section 15.6.3
503 the output is already being captured, the compositor is not serving the screen, the capture container refused the connection, is absent, or is not ready, or the Display has no status.node yet Retry-After: 5 sections 15.6.4 and 10.2.3
504 the capture container sent no headers within the header timeout section 15.6.5
{
  "type": "https://display.liken.sh/problems/capture-denied",
  "title": "The compositor denied the capture",
  "status": 500,
  "detail": "unauthorized",
  "instance": "/v1/display/displays/HDMI-A-1/screen.png#7f3c2a19"
}
Type URI Meaning Status
https://liken.sh/problems/no-node the Display has no status.node yet 503
https://liken.sh/problems/not-acceptable Accept excludes everything the route can serve 406
https://liken.sh/problems/capture-busy the output is already being captured 503
https://liken.sh/problems/upstream-failed the capture container refused the connection, is absent, or is not ready 503
https://liken.sh/problems/away the Display has no panel on its connector. detail is the Connected condition’s message 409
https://display.liken.sh/problems/capture-denied the compositor denied the capture. detail is its unauthorized 500
https://display.liken.sh/problems/compositor-down the compositor is not serving the screen. detail is the CompositorServing condition’s message 503
https://display.liken.sh/problems/encoder-failed the encode produced no picture 500

The first five types are the ones the three capture APIs share. The last three are this API’s own, because only the display domain has a compositor or an encoder. A screen with no node and a screen whose compositor is down are both a 503, with different types, because you are waiting for different things: the scheduler in one case, and the operator starting the compositor again in the other. A screen with no panel is away. It is a 409 with no Retry-After, because no wait brings an unplugged monitor back: you connect the monitor, or you ask for another screen. The audio API answers the same type for a Sink or a Source with no node. An error with no type of its own has type: about:blank and the status phrase as its title. The OpenAPI document lists every type.

When detail describes a failure on a node, it names the node and what went wrong, for example “the capture sidecar on node-2 did not present a certificate this API trusts”. It never includes the pod’s address, the port of the private connection, or the path this API called. Those describe the shape of the cluster, and a caller who may read screens does not need them. The API’s log line for the request has the whole dial error under the same request id.

Discovery

GET /v1/display returns the discovery document. All three capture APIs use the same shape. It lists the resources the API serves, keyed by plural name. Each resource has a self template and its aspects. Each aspect has an RFC 6570 URI template, its media types, its extensions, and a redirects flag. Here redirects is false, because a Display is the thing captured and not a pointer to something else. A client that expands the template sends t=5%2C7, which the parser accepts.

{
  "resources": {
    "displays": {
      "self": "/v1/display/displays/{name}",
      "aspects": {
        "screen": {
          "template": "/v1/display/displays/{name}/screen{.ext}{?t,xywh,width,height,framerate,quality}",
          "mediaTypes": ["image/png", "image/jpeg", "video/mp4", "multipart/x-mixed-replace"],
          "extensions": ["png", "jpg", "mp4", "mjpeg"],
          "redirects": false
        }
      }
    }
  },
  "openapi": "/v1/display/openapi.json"
}

Examples

This version has no CORS support and no kubectl plugin. A browser can only reach the API on the same origin through a port-forward. The everyday tool is curl.

With a ServiceAccount token:

kubectl -n liken-system port-forward svc/display-api 8443:443 &
kubectl -n liken-system get configmap display-api-ca \
  -o jsonpath='{.data.ca\.crt}' > display-api-ca.crt
TOKEN=$(kubectl -n liken-system create token display-viewer --audience display-api --duration 10m)
curl --cacert display-api-ca.crt -H "Authorization: Bearer $TOKEN" \
  https://localhost:8443/v1/display/displays/HDMI-A-1/screen.png > screen.png
curl --cacert display-api-ca.crt -H "Authorization: Bearer $TOKEN" \
  'https://localhost:8443/v1/display/displays/HDMI-A-1/screen.mp4?t=,10' > clip.mp4

With the client certificate from your kubeconfig, there is no token to mint. The port-forward is a TCP tunnel, so the TLS handshake runs end to end and the certificate reaches the API unchanged:

kubectl config view --raw --minify \
  -o jsonpath='{.users[0].user.client-certificate-data}' | base64 -d > client.crt
kubectl config view --raw --minify \
  -o jsonpath='{.users[0].user.client-key-data}' | base64 -d > client.key
curl --cert client.crt --key client.key --cacert display-api-ca.crt \
  https://localhost:8443/v1/display/displays/HDMI-A-1/screen.png > screen.png

From a pod on the cluster network, the same two files work against https://display-api.liken-system.svc/v1/display/displays/HDMI-A-1/screen.png with no port-forward.

Port-forward limits. A port-forward is fine for a still and bad for a stream. It is a single TCP connection through the API server. In our tests it moved about 2 Mbit/s: a 3 second MJPEG stream that the node captured in 3.06 s took 25 s to read through the forward, and 3.09 s from a pod on the cluster network. A 1080p MJPEG stream needs about 15 Mbit/s, so through the forward you see it at about one eighth of real time. Stills are not affected. screen.png took 0.9 s to first byte through the forward and 0.76 s from the cluster network. Read a stream from a pod on the cluster network, or through a Service the cluster owner exposes.

Notes

The codec string. screen.mp4 names its codec in Content-Type the way RFC 6381 specifies. avc1.640029 is High profile, no constraint flags, level 4.1, which covers every output up to 1920x1080 at 60 fps. avc1.640033 is level 5.1, for outputs above that. The encoder pins the profile and level instead of letting them follow the stream, so the parameter is correct before the first byte. The info document has the same string in its codecs field, and media-api copies it from this header into the Content-Type of a composed stream.

A re-minted Secret. If you delete the Secret display-capture-server, the API mints it again as soon as its watch reports the delete. The screens come back when the kubelet next syncs the projected volume, and that sync period is the kubelet’s own, which nothing in this operator controls. A capture container reads the new files as the kubelet writes them. A capture container that still has the old leaf certificate keeps returning 200 the whole time, because that leaf is still valid and this API still trusts it. You only see a 503 where the capture container also restarted and found no certificate files. Then the detail reads “the capture sidecar on node-2 did not present a certificate this API trusts”.

The info route on a screen that is down. You do not need the screen to be up to ask what it is. GET /v1/display/displays/{name} returns 200 for a screen with no panel on its connector, for a screen whose compositor is not serving, and for a node this API cannot reach. The document has the name, the node, and the size and refresh rate from the Display status, plus panel: disconnected, compositor: down, or sidecar: unreachable with the words of the cause in detail. scale, formats, and conversion come from the node, so they are absent instead of guessed.

What a capture costs. A capture turns off the compositor’s hardware planes for its whole length. A film that a plane would normally show goes through the GL renderer instead while a clip runs. On a node whose driver has no VA-API post-processing, the color conversion runs on the CPU too, which costs about four times the cores at 1080p. The info document’s conversion field names the pipeline the node uses, and the display_capture_conversion metric reports the same value to a dashboard.