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.