Multi-seat contract
The rules between a Punktfunk host and its seat supervisor — the control channel, the environment a seat host starts with, the Windows connector reservation, and on Linux the door, the owner's row and a seat on a person's own account.
The rules between the host and its seat supervisor, which runs one host per seat so several people can play on one box. A normal install has one host on the console session, and none of this applies to it. Setup and what players see: Seats on Windows, Profiles and Someone's own account. Linux seats are further down.
The supervisor is crates/host/pf-seats. A platform plugs in through PlatformBackend
(src/backend.rs), whose doc comments are the backend's half of the contract. The host reads its
half in one place, pf_paths::seat (crates/host/pf-paths/src/seat.rs).
The supervisor
| Windows | Linux | |
|---|---|---|
| Runs as | The PunktfunkHost service, LocalSystem in session 0, beside the console host | The root daemon punktfunk-seats (punktfunk-seats.service) |
| Endpoint | \\.\pipe\punktfunk-seats | /run/punktfunk/seats.sock, 0660 root:punktfunk-door |
| Who may connect | SYSTEM and Administrators; remote clients are refused | root and the punktfunk user, by the peer's credentials. Once the owner's row is adopted, the owner's uid too, for install_flatpak and remove_flatpak only |
| Ledger | %ProgramData%\punktfunk\seats\ | /var/lib/punktfunk-seats/seats/ |
| The client waits | 60 s for list, 30 min for enable with prepare, 5 min for the rest | 30 min for a Flatpak change, 180 s for the rest, 30 s more for an unlock |
Each connection carries one request, and at most eight are served at once. A frame is a four-byte
big-endian length, then JSON, 64 KiB at most. A request is
{"schema_version": 1, "command": {"type": "<command>", …}}; the answer is
{"status": "success", "schema_version": 1, "result": {"kind": "<result>", …}}, or
"status": "error" with error: {code, message}. The types are Request, Command and
Response in crates/host/pf-seats/src/ipc.rs. A request carries no credential of its own:
adopt, adopt_user, credential and unlock carry an account's password, which the supervisor
seals (Windows) or checks once (Linux) and forgets. On Linux a peer or command the socket refuses
is closed unanswered.
| Commands | Where |
|---|---|
list, create, start, stop, delete, doctor, seating, enable, disable | Both. On Linux enable answers like seating, and disable is refused: seats are on while the daemon runs. |
adopt, credential | Windows: a seat on an existing account |
adopt_owner, install_flatpak, remove_flatpak | Linux: the owner's row, box-wide Flatpaks |
adopt_user, unlock, unlock_state, challenge, left, sign_out, devices | Linux: a seat on a person's own account |
On Windows the supervisor owns the seat accounts, the Windows sessions and RDP. The console host talks only to a pipe that LocalSystem serves, since the name is free while the service is down. Seat hosts run in jobs of the supervisor's own, so a console logon or a console host restart leaves them running; stopping the service logs every seat account off. After a crash the next start does the same for sessions a seat's games left behind.
- The keeper.
punktfunk-seat-keeper.exe, besidepunktfunk-host.exe, holds one seat's loopback RDP session open. The supervisor hands it the seat's credentials on stdin, never on the command line. Runpunktfunk-seat-keeper trustfrom an elevated prompt once, before the first seat: it records the RDP certificate the keeper will accept. Turning seats on does this too, and replaces an earlier record. Each seat start compares the certificate TermService presents now with the record; a change fails the seat withrdp_pin_changeduntil seats are turned off and on. The keeper's stderr goes to the seat'shost.log. - The seats group. Every seat account is in the local group
punktfunk-seats, rejoined at each start. The host denies that group on%ProgramData%\punktfunk\ingest, so a seat can't replace the owner's Playnite library. The doctor'saccountrow reports the membership. - Seats on and off. Without the reservation marker below the ledger is still listed, and
every start answers
seats_off. Turn seats on and off writes the marker.
A seat on an existing account
A seat signs in to an account the supervisor made, pf_seat1 to pf_seat4, or to a local
standard account the box already has. The console asks for the second with seat: true,
seat_account and seat_password on POST /api/v1/profiles. PUT /api/v1/profiles/{id} with
seat_password replaces its password.
- Commands.
adoptcarriesseat, ascreatedoes, plus the account'ssidandpassword, and answerscreated.credentialcarriesidandpassword, checks the password with a sign-in, seals it in place of the old one and answersresealedwith the row. - Attempts. Two wrong passwords for one account, by
adoptandcredentialtogether, reach the sign-in in any 15 minutes. A third answersunlock_locked_outwith the wait in its sentence before any sign-in, so the supervisor never drives the account into Windows' lockout. - The row. An adopted row has
adopted_sid, the account's SID, andrdp_granted,truewhen adopt put the account in Remote Desktop Users. A made account is the row's when its comment holds the seat's marker; an adopted one when its name resolves toadopted_sid. That SID is a local user's,S-1-5-21-<n>-<n>-<n>-<rid>with a RID of 1000 or more. - Names. An adopted account's name follows the SAM rule: 1 to 20 characters, spaces and
non-ASCII letters allowed, no control characters, none of
"/\[]:;|=,+*?<>. A made one stayspf_seat<n>. - Passwords. 1 to 256 bytes of UTF-8, no control characters, sealed with DPAPI under SYSTEM as a made account's is.
- Adopt refuses an administrator, an account denied Remote Desktop sign-in, an account with a
session on this PC, and a password that doesn't sign in. Then it seals the password, adds the
account to Remote Desktop Users where it is missing and to
punktfunk-seats, and grants itSeDenyInteractiveLogonRight. It sets no account flag and writes no comment. A step that fails undoes the ones before it. - Release is
deleteon an adopted row, by the row's SID. It removes the deny right, thepunktfunk-seatsmembership, Remote Desktop Users only whenrdp_granted, the sealed password, and the Steam launchers seats wrote into the account's hive:SeatSteam, oursteam://handler, and aRun\Steamline naming a seat's IPC name. It never deletes the account or its profile. - Picker.
GET /api/v1/profiles/windows-accountsserves Existing account and take-turns profiles alike. An account is free when no ledger row names it, by name or by SID, no take-turns profile binds it, and its name isn'tpf_seat*.
The reservation marker
HKLM\SOFTWARE\Punktfunk\SeatsThe key's existence is the signal; its values are ignored. It splits the virtual-display connectors:
| Host | Without the key | With the key |
|---|---|---|
| Console host | 0–15 | 0–11 (0 serves clients without an identity) |
| Seat host | refuses every virtual-display session | exactly one of 12–15, from PUNKTFUNK_SEAT_DISPLAY_SLOT |
- Only an elevated operator writes the key. It lives under
HKLMso a seat host, an ordinary process, can't reserve connectors from its own environment. - When the driver places a monitor outside the host's range, the host removes it and refuses the session.
Turn seats on and off
The management API turns seats on from the box's console. These routes take the management token, never a paired device or a plugin.
| Request | Does |
|---|---|
GET /api/v1/profiles/seating | Returns enabled, platform (windows, linux on a door, or other) and checks: Windows Server, the Remote Desktop Session Host role, licensing and a graphics card. A desktop edition whose TermService loads a wrapper DLL instead of termsrv.dll passes with a warning and skips the role and licensing checks. |
PUT /api/v1/profiles/seating with {"enabled": true, "allow_rdp_from_network": false} | Turns seats on. |
The same with "prepare": true | First installs RDS-RD-Server and sets licensing to Per User where missing, then turns seats on. A role that needs a restart answers a restart error and leaves seats off. |
PUT /api/v1/profiles/seating with {"enabled": false} | Stops every seat and turns seats off. The accounts stay. |
GET /api/v1/profiles/doctor | Returns the supervisor's report: one sentence per row, seat_id and seat_name on a seat's rows. |
A failed check changes nothing: the answer is 200 with enabled: false and the check as an
error. When every check passes, turning on:
- Writes the reservation marker with an
Enablingvalue, which step 5 removes. A key that still holds it, after a crash, reads as seats off; turning on again finishes it. - Installs
pf_vdisplay_seats.inffromstaging\pfvdisplay\besidepunktfunk-host.exe. - Turns the Remote Desktop listener on if it was off, and limits the Remote Desktop
firewall rules to
127.0.0.0/8unlessallow_rdp_from_networkistrue, with the two port 3389 rules the Session Host role adds. Each rule's scope is noted first, and turning off writes it back. Withallow_rdp_from_networkthe inbound rules on port 3389 are enabled if they were off; Shadow stays as it is. The service limits them again each time it starts. - Adds the seat listener
punktfunk-tcp: a copy ofRDP-Tcpon port 3390 whose sessions loadpf_seat_audio.dll, with port 3390 blocked from every address but loopback. The x64 build goes toSystem32\punktfunkand the x86 build toSysWOW64\punktfunk. Every seat start brings them up to date. Not on ARM64. - Records the Remote Desktop certificate, as
punktfunk-seat-keeper trust --replacedoes.
The keeper connects to 3390 when it listens, else to 3389. TermService opens and closes a listener as its key appears and goes; nothing restarts.
Turning off puts the listener and the firewall scope back, removes the seat listener and its
files, points the box's Steam shortcuts back at Steam, and removes the marker. A file the audio
services still hold goes at the next restart. The seat display driver stays in the driver store.
punktfunk-host seats off does the same without the service; the uninstaller runs it. Off
Windows and off a door, GET answers enabled: false with platform: other, and PUT answers
409.
Every seat start compares the DriverVer of staging\pfvdisplay\pf_vdisplay_seats.inf with the
seats packages in the driver store and adds the staged one when none matches: an uninstall's
driver uninstall deletes the package, and an upgrade only stages the new one. A running seat
keeps its driver until its next session.
The environment a seat host is started with
The supervisor starts an ordinary punktfunk-host serve with these. Don't set them by hand; set
them together. The host logs a warning at startup for each one that is set and ignored.
| Variable | Value | Rule |
|---|---|---|
PUNKTFUNK_SEAT_SESSION | 1 | Marks a seat host. Unset or any other value is the console host. A seat host is never the door: --door is ignored on it. |
PUNKTFUNK_SEAT_ID | 32 lowercase hexadecimal characters | Required with PUNKTFUNK_SEAT_SESSION=1. Missing or any other form: a seat host with PUNKTFUNK_TRUST_DIR doesn't start, and none mints audio devices. Ignored without PUNKTFUNK_SEAT_SESSION=1. |
PUNKTFUNK_SEAT_DISPLAY_SLOT | Windows: 12–15 | The seat's connector. Not a number, out of range, or the marker absent: the host refuses every virtual-display session. |
PUNKTFUNK_TRUST_DIR | Windows: the box's config directory. Linux: the seat's trust copy | The seat reads the box's pairing store, profiles.json, per-device display overlays and seat-defaults.json from here, read only, and follows their changes. A relative path is ignored. |
PUNKTFUNK_PAIRING | refused | Devices pair with the box. A knock is refused and no PIN window opens. A seat host refuses pairing with or without it. |
PUNKTFUNK_LIBRARY_DIR | Windows: the box's config directory. Linux: the seat's trust copy, on every seat but the owner's | The seat reads the box's library from here, read only: library*.json, library-metadata/, the plugin grants and the firmware plugins staged; on Windows also the plugin manifests its entries launch through and the managed emulators. Play stats stay in the seat's own directory. A relative path is ignored. |
PUNKTFUNK_SEAT_OWN_ACCOUNT | 1 on a seat on a person's own account, Windows and Linux | The host's one sign that it runs as a real person. Unset on a made seat and on the Linux owner's row. |
PUNKTFUNK_SEAT_OWNER | Linux: 1 on the owner's row | The seat host is the owner's own, and also serves the owner and the profiles that share the owner's desktop. |
PUNKTFUNK_SEAT_HEADLESS | Linux: 1 on the owner's row, set by the runner, not the environment file | The host runs in the row's headless session, not at the desk. |
PUNKTFUNK_SEAT_STEAM | Windows: the seat's own steam.exe, when the box has Steam | Steam runs once per IPC name per machine, so a seat starts Steam from here only, always with -master_ipc_name_override pfseat<seat id>. The supervisor copies the box's client into the seat account's profile at the seat's first start; until the file exists, the seat's Steam titles don't start. Unset or relative: no Steam on the seat. |
It also sets these ordinary host settings, so seats don't collide. A seat presents its own certificate and honours the box's pairings and grants.
| Variable | Supervisor's value |
|---|---|
PUNKTFUNK_CONFIG_DIR | A per-seat directory. Linux: the seat home's .config/punktfunk, seats/<id>/config on a person's row, unset on the owner's row |
PUNKTFUNK_MGMT_BIND | 127.0.0.1:<per-seat port> |
PUNKTFUNK_NATIVE_PORT | a per-seat port |
PUNKTFUNK_HOST_NAME, PUNKTFUNK_GAMESTREAM | the seat's name, and 0. Unset on the Linux owner's row |
PUNKTFUNK_MDNS | 0 |
PUNKTFUNK_AUDIO_OUTPUT_MODE | Windows: follow_default |
PUNKTFUNK_WEBTRANSPORT | Linux: 1 while the box's Seat defaults stream to browsers (browser), else 0 |
PUNKTFUNK_WEBTRANSPORT_PORT, PUNKTFUNK_WEBTRANSPORT_ORIGINS | Linux, with PUNKTFUNK_WEBTRANSPORT=1: the seat's browser port, UDP 9783–9786, and the box's browser_origins |
PUNKTFUNK_MGMT_TOKEN | Linux: fresh at each start, and written to seats/hosts/<id>/mgmt-token so the box host reaches the seat's loopback API |
The supervisor must keep each seat's RDP session active. A seat host runs outside the console session by design, and display activation fails while its session is inactive.
What the host does differently on a seat
- The seat contract, not a display policy. A seat that is not the owner's own reads no
display-settings.jsonof its own. Its desktop is one virtual screen at the occupant's mode: it never mirrors, keeps lit, powers off or PnP-disables a real monitor, and never routes a game to a session of its own. A second device shares the occupant's screen when the box would join it, by its ownmode_conflictor the box's, and is refused as busy otherwise, as the door refuses it before a redirect. Of a device's display settings in the box console, the largest mode, the scale and that rule follow it onto a seat. Linger, topology and identity are the unconfigured host's.GET /display/settingslists nothing inenforcedand onlymax_mode,scaleandmode_conflictper device; every display or session settings write answers 409. - The Seat defaults. The box writes
seat-defaults.jsoninto its config directory at start and after each settings change, and the seat re-reads it when it changes, so a change reaches the seat's next session. Its keys:end_on_game_exit(a seat's stream ends when its game exits),max_mode(the largest mode a seat grants a device without a cap of its own),library_writable(Linux: seats update and install into the owner's Steam libraries; the daemon reads it from the box's copy when a seat starts),dualsense_usbip(the box's own DualSense over USB/IP switch: a seat's DualSense is a USB/IP pad with a sound card exactly when the box's is),browserandbrowser_origins(each seat runs a browser plane of its own, under those origins) andbackground_input(Windows: every app a seat starts reads GameInput in the background). Session settings the seat's profile holds in its own layer (below) come first;end_on_game_exitapplies where it holds none. On a person's own Linux account a game's exit always returns to the desktop. The box itself applies Seats kept warm and Stop idle seats. - The box's trust, its own key. The seat never writes the pairing store or profiles. A grant
changed in the box console applies to the seat's next check, and within about three seconds to
its live sessions: a narrowed grant takes effect, and an unpair, an expiry or a removed profile
ends the session. It mints and keeps its own identity
in
PUNKTFUNK_CONFIG_DIR, never the box's. The supervisor records its certificate's SHA-256 in the ledger while it runs. The box'sRedirectcarries that pin (field 7) andenumeratelists it asseat.pin, so a client pins the seat through the box it already trusts. A seat whose pin the box doesn't hold yet is unavailable. Sleep, restart and shut down are refused: the box's own host is the one that does them, and it ends the sessions on every seat first. - The box's library, on Windows. A Windows seat runs none of the box's plugins. It lists and
launches the box's titles as its own user, and answers every change to the box's library with
409. Titles from sources
that belong to one account (Playnite, Game Bar, Amazon, itch, Hydra) are left out. It runs the
box's emulators from
%ProgramData%\punktfunk\emulatorswith the firmware the box's plugins staged. The box host tails each running seat'sgame.*into its own bus, withseatnaming the profile: its plugins hear them, its hooks don't. A seat's held launch (below) runs the box host's plugins, which then release it. The box host's save and firmware routes take?profile=<id>for a profile with a seat: they read and write that seat account's copies, in its profile folder. A seat host answers 409. - The box's library, on Linux. The box's library is the owner's. A seat other than the
owner's runs none of the box's plugins, and of the box's library lists and launches only what
it can start as its own user:
steam_appidwith an id that fits 32 bits andsteam_ui, with its own Steam, andemulator, with a copy it can run and a game file it can read. The seat answers every change to the box's library with 409. The owner's host installs a Flathub emulator box-wide throughinstall_flatpak, so every seat runs it with its own config and saves in~/.var/app/<id>; which emulators stay the owner's is in Profiles. At a launch the seat installs the RetroArch core the entry names into its own RetroArch and places the firmware from its library copy. - Its own runner's titles. What a plugin of the seat account's own runner
publishes with
PUT library/provider/{provider}lands in the seat host's own titles (below), underown:ids. On a made Linux seat that runner is the seat account'spunktfunk-scriptinguser unit, which reads the seat's config directory, where the package enables the unit for every account. On a person's own Linux account it is their seat's own runner, and on Windows the seat's runner. A?store=claim is dropped there, so no row shadows a title of the box's. The provider's running and download reports read those rows, and the box learns their names and pictures as it learns the others'. On a person's own account only their session and their plugins write them, and a provider row neither wrote lists nothing. - Its own runner's folders. A plugin installed in the seat's own
plugins/reads its manifest and grants from the seat's own config directory, never the box's for a plugin of the same id; any other plugin reads the box's copy. The operator decides them on a made seat, the seat's person on both kinds of seat, and a folder the seat's account can't list is refused (not_readable). On a person's own account, and to a Windows seat's person, an emulator or core install asked through a grant stays refused (emulators_own_account_yet). On Windows the seat's account lists the folder and places its access itself, so a grant opens only a folder whose permissions that account may change. - One profile. The seat serves the profile whose seat it is. A connect that names any other profile, the owner's included, is refused as an unknown profile; one that names none plays as this seat's profile. The owner's row alone also serves the owner and the profiles that share the owner's desktop.
- A profile's own layer. Each profile but the owner may keep a layer of its own over the box's
library, in the library directory at
library-profiles/<profile id>/:library-hidden.json,library-scanners.json,library-overrides.json,library-picks.jsonandsession-settings.json, each the shape of the box's file. Only the host-minted id (12 lowercase hex) names a folder. Nothing in a layer runs: no custom entry, command,prepor hook. The host that writes the library writes the layers, and removing a profile removes its folder. A layer adds and narrows, never widens: a title is hidden, or a source off, when the box or the profile says so; the profile's game override (for the game or its source) and its art pick for a slot win over the box's. Session settings are the profile's file when it has one, and only a profile with a seat of its own has them. A seat reads its own profile's folder, found from the box'sprofiles.jsonin its trust directory, for its list, launches, art, game overrides and session settings; the owner's host applies the layer of the profile a session resolved to, sharing the owner's desktop or taking turns, at launch and for game overrides. On the box,?as=<profile id>on the library routes andsession/settingsreads and writes that layer, and a profile with a seat of its own lists there what its seat lists.DELETE session/settings?as=drops the profile's own session settings, so its seat takes the Seat defaults again. - A profile's own titles. A seat host keeps titles of its own, launch and
prepincluded, inlibrary.jsonin its own config directory. The custom-entry routes write there on a seat host, underown:<12 hex>ids the box never mints; a write naming any other id answers 409. On a person's own account only their own session writes one; any other lane answers 403library_own_account_person, orlibrary_own_accountwhere that lane is closed. Its list carries them on top of its profile's view, markedown, and they launch, runprepand serve art as the seat's user, past the rules for what a seat lists of the box's.GET library/ownanswers each one's id, title, platform andhttp(s)art, never how it starts. The box asks for that when a seat's host comes up and on eachlibrary.changedthe seat sends, and stores it throughPUT library/own?as=<profile id>on the host that writes the library: the owner's host behind a door, the box host on Windows. It keeps it asown-titles.jsonin the profile's layer, under the profile the seat belongs to, dropping a row with a bad id or title and a cover or platform it would not store; an answer that isn't a list, lists more than 500 or repeats an id leaves the stored titles as they were.?as=and the door's answer to a device list those rows, markedown, with no launch: the box refuses to start one with the sentence a title gets that this desktop can't start. - Not a host of its own to clients. A seat host never advertises over mDNS, whatever
PUNKTFUNK_MDNSsays, and itsWelcomecarries management port0: its API answers on loopback only, so a client keeps the box's port, where the library comes from. - No status tray. A seat host doesn't start or supervise one; the supervisor is the control surface.
A Windows seat host
- One connector, one lock. A seat host creates its virtual display on its own connector only
and holds the mutex
Global\punktfunk-vdisplay-manager-seat-<slot>; the console host holdsGlobal\punktfunk-vdisplay-manager. Hosts on different connectors start independently. A second host on the same connector can't open the display driver. - Driver calls are per process. Every host sees, changes and clears only the monitors it created. A crashed host's monitors depart when its handles close or it misses the driver watchdog, so no host reaps a neighbour's.
- Launches stay in its session. Games, store launches and hook commands run as the signed-in user of the host's own Windows session, not the console's.
- Its audio devices are its own. It mints
Punktfunk Speakers [seat <id>]andPunktfunk Microphone [seat <id>], matches them by a marker derived from the seat id, and never adopts a neighbour's. It captures its own speakers and never changes the box's default playback or recording device. - Its virtual mouse is its own. The resident HID mouse that makes Windows draw a cursor into
the stream is
pf_mouse_<slot>, with mailboxGlobal\pfmouse-boot-<slot>. The console host uses index0. Only the console host sends input through it: HID input reaches the console session, so a seat host injects withSendInput. - Its pads are its own. Every virtual device a host creates, its pads and its virtual mouse,
carries the host's session id (
DEVPKEY_Device_SessionId), so another session can't list it, open it or receive its input. A pad the host can't fence is not created. The box has 16 pad indices; each host takes the lowest one that no other host's pad holds. - The desk's controllers are the desk's. The box's host, which runs in the console session, writes that session into every physical game controller present and into each one that arrives, so a seat's HID, XUSB and XInput listings and its GameInput deliveries leave them alone; a device a Remote Desktop session redirected in keeps its own session. The tag is wiped by the device's next start and written again on its arrival; the service's stop and the uninstaller clear it. What Windows still shows a seat: Seats on Windows.
- Its pads' audio is its own. A Remote Desktop session lists only what its listener's audio
endpoint enumerator returns. Through the seat listener, that is Remote Audio plus one
"Wireless Controller" render endpoint per DualSense the seat host serves, in that pad's container
and in that session alone. The seat host claims a pad's index by creating
Global\punktfunk-seat-pad-<session>-<index>and its ring…-pcm, before the pad appears; the endpoint inside audiodg writes what games render there into the ring, and the seat host streams it as pad audio. A seat onRDP-Tcphas no pad audio. - Steam starts as the seat's copy. Once the copy exists, each seat start writes the seat
account's
Software\Punktfunk\SeatSteam(Exe,Ipc=pfseat<seat id>), asteam://handler in its classes hive (marker valuePunktfunkSeat) and Steam'sRunentry, all naming the copy under its IPC name. SYSTEM owns both keys; the seat may only read them. The box's public DesktopSteam.lnkand the shared Start menu'sSteam\Steam.lnkstartpunktfunk-tray.exe --steam "<their steam.exe>" <their arguments>, which starts the copy for an account withSeatSteamand the box's Steam for any other. Their targets are kept in the Seats key'sSteamLinksand put back when seats go off.
A profile's lock
profile-locks.json sits beside the box's pairing store. The box's own host, the plain host or
the door, is its one writer, 0600 on Linux and SYSTEM and Administrators on Windows; it is never
in profiles.json. Its profiles map each profile id to locked, an optional pin
(iterations, salt, hash: PBKDF2-HMAC-SHA256, 600,000 iterations, a 16-byte salt) and the
devices list of a person's row's devices.json, where a device the operator added is stay and
added. Removing a profile removes its entry; unlocking keeps the list and the PIN, and locking
replaces the list with the devices the lock names. One desktop has one lock: the owner is the
first profile whose os_account is operator, and any later one shares its desktop, has no
entry and opens with the owner's lock, list and PIN. A take-turns profile keeps its own.
- The check. A device opens a profile when the profile is unlocked, or its list admits the
device:
stay, or present. A device the list holds a key for is asked for the key's proof or the PIN. Any other device is refused, or asked for the PIN when the profile has one. Every host asks it after a connect resolves its profile and before the profile is placed or played, the door and a seat host alike. A wake, the picker (enumerate), a device's library read as a profile (as=, answered as an unknown id), the door's forwarding of a device's own play, and a GameStream launch or resume (as the owner) ask it too. A session, Punktfunk or GameStream, whose device stops being admitted ends within a second. On a door a Moonlight device is paired with the owner's host: adding one to a list asks that host'sGET /clients. - A file that doesn't read. Every profile is refused to every device, every edit is refused, and the host logs it. A missing file locks nothing.
- The PIN. Only the box's own host checks it, sent as
ProfileUnlockkind 1. A device waits after wrong ones as after a wrong account password; 10 wrong for one profile within 15 minutes, over all devices, close PIN entry for it until the oldest is 15 minutes old. Every further ten wrong PINs double that window, up to a day (pin_missesin the file counts them); a right PIN or a new PIN puts it back. A device's waits live in memory; a profile's count is itswrong_pins, unix seconds, in the file, so a restart clears none. A try counts there before its hash runs and is taken back when right. A right PIN makes the device present, orstaywhen the message asks. Each try writes one line underpunktfunk::audit, never the PIN. - The key. A right PIN that brings a key to enrol, as a password does, stores it in the
device's entry's
keys, under the profile id it was typed for; one the host can't verify with is left out. Profiles that share a lock keep a key each, at most 8 an entry, and a profile's removal drops its keys. An entry'skeyfrom beforekeysopens any profile of its lock until a proof makes it that profile's. A device with a key is admitted only while present. Once it left,enumeratesayspin_keyon a profile it holds a key for, andPOST /profiles/{id}/unlock-challengeanswers{challenge, profile}: 32 bytes as 64 hex, valid 120 seconds and once, the device's only live one for that id. The key signs"punktfunk-profile-unlock-v1"‖ challenge ‖ the profile id, zero bytes after it to 64 ‖ the device fingerprint ‖ the fingerprint of the host the device dialled, 187 bytes, signed as the seat form is, and verified with that profile's key alone;clients/shared/profile-unlock-vectors.jsonholds both. A right proof makes the device present. A failed one costs the device's limit alone and answersclose_lock_key_failed, orclose_lock_not_allowedwhen the profile has no PIN. A device the console adds drops its keys and stays until signed out; signing it out drops them too. - A seat host reads the box's file, read only, and never takes a PIN or a key proof. A Windows seat reads it
from
PUNKTFUNK_TRUST_DIR. A Linux seat reads the trust copy, which holds its profile's lock with no PIN; a locked profile's seat gets only the pairing records its lock admits, as a person's row does, and the owner's row follows the owner's lock. A lock file there that doesn't read gives an empty pairing list. - The person. A device a locked profile's list admits now, with nothing asked, is that
profile's person, unless the profile is the owner or on a person's own account. With
?as=<id>it may callmgmt_routes::PERSON:PUT library/hidden/{id},PUT library/overrides/games/{id},PUT library/overrides/sources/{source},PUT library/scanners/{id}, andPUTandDELETE session/settings, which write that profile's own layer alone. A hide or a game's entry it adds must name a title the profile's library lists, and a source's entry a source with such a title; taking one away needs none. With noasa device isn't let in. A device that isn't the person, an open profile, one that needs its PIN and the owner's id answer 403profile_change_not_allowed; an unknown id 404profile_unknown. Art picks stay off: the host fetches a pick's URL itself. Asked on every call. The box's own host decides, the door on a door: it passes the call to the owner's host, which writes the library, carryingX-Punktfunk-PersonbesideX-Punktfunk-Device. That host honours it only with its admin token, on these routes, for the profileasnames and never the owner, and needs no pairing record for the device. Any other seat host decides nothing.
Linux and Windows, side by side
A row with one answer holds on both. Every other row is a difference the sections above state.
| What a profile has | Linux | Windows |
|---|---|---|
| Its own layer: hidden titles, sources, game overrides, session settings | Yes | Yes |
| Its lock, PIN and device key | The door checks | The box host checks |
A device as its person, on mgmt_routes::PERSON | The door decides, the owner's host writes | The box host decides and writes |
Its own titles, with a command and prep | On its seat host; the owner's host stores the list | On its seat host; the box host stores the list |
| The person's own session | On a seat's own host, made or on their own account; the ticket from a 0600 file | The same; the ticket from the seat host's person pipe |
| What that session reaches on the seat host | Every profile-data and profile-exec route, and their plugins' pages | Their hooks, their own titles, the store, their runner, their plugins' pages and folder decisions |
| Hooks on a made seat | The operator and the person | The operator and the person |
| Hooks on a person's own account | The person, in their session | The person, in their session |
| Plugins on a made seat | The seat account's own runner | The seat's own runner, as the seat's account; its person installs from the built-in source alone |
| Plugins on a person's own account | Their seat's own runner, which the person alone switches and installs into, in their session | The same |
| The box's titles on its seat | Steam and emulator titles it can start as its own user | Every title but those of one account's sources |
| Emulators | Installed box-wide; config and saves are the account's | The box's copies; saves and firmware through the box host's ?profile= |
| A seat on an existing account | Yes, opened by its password or a device key | On Windows Server |
A plugin with no listener of its own, as every Windows seat's is, serves its page through its seat host's relay, which the person's session reaches.
Checking a seat can actually stream
Run this inside the seat's session:
punktfunk-host spike --source virtual --seconds 5It creates a virtual display, captures it and encodes in the display driver. It exits non-zero on
any gap: no driver, no captured frame, no GPU encoder (a software encoder counts as a failure), or
no encoded output. The supervisor runs it before it reports a seat healthy. Add --hdr to also
require the 10-bit path.
The seat display driver is a second package
Windows' terminal-services stack starts a seat's display with whichever driver claims the hardware
id RdpIdd_IndirectDisplay. Claiming it takes over every RDP session on the machine, ordinary
Remote Desktop included, so the build produces two packages from the same .inx and signing key:
| Package | Claims | Installed by |
|---|---|---|
pf_vdisplay.{inf,cat} | the console display device, Root\pf_vdisplay | every punktfunk install |
pf_vdisplay_seats.{inf,cat} | RdpIdd_IndirectDisplay only | turning seats on, and a seat start when the store lacks the staged version |
A machine that never turned seats on never claims the id.
Losing the claim is silent. Windows' own rdpidd.inf ranks the same as ours (0x00FF0000), so
the newer DriverVer date wins. A seat on Microsoft's adapter still logs in and never streams.
Check it with at least one seat connected, since seat devnodes exist only while a session does:
powershell -File check-seat-display.ps1check-seat-display.ps1
reports the driver on each live seat display and exits 1 if any isn't ours or none is live. It
warns when our driver date isn't newer than rdpidd.inf's, and on a client edition.
Seat audio needs a driver on disk
A seat usually has no sound card, so the host mints a render endpoint per seat by binding Valve's
Remote Play streaming drivers. SteamStreamingSpeakers.inf and SteamStreamingMicrophone.inf must
be bound to a device already, or sit in
%CommonProgramFiles(x86)%\Steam\drivers\Windows10\<x64|arm64>\, where Steam's first start puts
them (Prepare Windows Server). Without them a seat
streams video with no audio. Pad audio needs neither: it comes from the seat listener.
powershell -File check-seat-audio.ps1check-seat-audio.ps1
exits 1 when neither is present. A virtual cable is no substitute: the host never picks one for
desktop audio, PUNKTFUNK_MIC_DEVICE only pins the microphone, and a cable pinned with
PUNKTFUNK_LOOPBACK_DEVICE is shared by every seat.
Linux seats
On Linux the supervisor is the root daemon punktfunk-seats (punktfunk-seats.service), not part
of the box host. Its state is /var/lib/punktfunk-seats, root's alone; the paths below are under
it. The box host's directory, /var/lib/punktfunk, is the door's; the daemon reads it for the
trust copies. It starts one punktfunk-seat@<user>.service per seat: a system-range user,
pf-seat-<n>, with a logind session of its own, a headless compositor (kwin_wayland, else
gamescope) and a stock punktfunk-host serve, all run by /usr/libexec/punktfunk/seat-session.
punktfunk-seats doctor checks systemd, logind, the groups, a GPU render node, a compositor and
the installed unit.
-
The package.
punktfunk-seatsis its own package. The host's rpm, deb and pacman packages depend on it at their own version, and it installs the units without enabling them. NixOS turns it on withservices.punktfunk.seats.enable. The SteamOS on-device build lays the same files out under/opt/punktfunkand/etc(scripts/steamdeck/door.sh). -
The CLI.
punktfunk-seats list,create <name>,adopt-owner <user>,start|stop|delete <id>,doctorandseatingsend the socket's requests;release-ownerdeletes the owner's row andstop-allstops every live seat.check-owner on|off <user>sends none: it applies the switch rules below and prints<uid> <home>orgone. -
Box-wide Flatpaks.
install_flatpak {id}adds Flathub to the system installation when the box lacks it and installs the app there, for every account; each user keeps its own data in~/.var/app/<id>.remove_flatpak {id, purge}uninstalls it;purgedeletes root's data of it, never a seat's. Both answerflatpak {id, version}. An id is reverse-DNS: letters, digits,.,_and-, at least one dot, at most 255 bytes, no leading-; any other is refused beforeflatpakruns. One change runs at a time: a second meanwhile answersconflict. -
The user. Its comment is
punktfunk-seat=<id>, and the daemon touches or deletes only an account whose comment matches exactly. The name ispf-seat-1topf-seat-4; a create for any other is refused. The home isseats/<id>/home, mode0700. It joinsrenderandpunktfunk-games, the shared games folder's group, which the owner joins too. Neverpunktfunk, which may power the box off, norinput, which reads every input device on the box. -
The environment. The variables go into
/run/punktfunk/seats/<user>.env(root0600), which systemd reads as root. The seat'smgmt-tokenis owned by thepunktfunkuser (root without one); every seat user sharespunktfunk-games, so no secret relies on group read. -
The trust copy. The seat reads
trust/<id>/, not the box directory:punktfunk1-paired.json,profiles.json,display-settings.json(from the library copy, the owner's),seat-defaults.jsonandprofiles/,root:<seat user>0640, and the seat's profile locks when the box has any. The daemon recopies a file within 2 seconds of its change while the seat runs. The box's key never crosses. A seat other than the owner's also finds the box's library there, itsPUNKTFUNK_LIBRARY_DIR. -
The box's library. The owner's host writes the library; its plugins scan the owner's home. From the owner's
~/.config/punktfunkthe daemon copieslibrary.json,library-hidden.json,library-metadata.json,library-overrides.json,library-picks.json,library-scanners.jsonandlibrary-metadata/tolibrary/,root:punktfunk-door0640, within 2 seconds of a change, and from there into each seat's trust copy. Each profile's layer,library-profiles/<profile id>/withlibrary-hidden.json,library-overrides.json,library-picks.json,library-scanners.json,own-titles.jsonandsession-settings.json, goes tolibrary/for every profile; a seat's trust copy gets only the folder of the profile whoseos_accountnames that seat in the box'sprofiles.json, and loses one that stops being its own.plugin-run/plugin-grants.jsongoes with them, and so does the firmware plugins staged,plugin-state/<provider>/firmware/<platform>/<file>at exactly that depth, up to 1 GiB a file. Nothing else ofplugin-runorplugin-stateis read: the plugins' tokens and sign-ins live there. A link, a file with a second link or one over its cap is not copied. Play stats stay with each host. A config folder it can't open, as in a home not mounted yet, leaves the copy as it was. Deleting the owner's row deleteslibrary/. -
Games.
games/steamapps/is one library every seat writes (grouppunktfunk-games, setgid, default ACL). Each seat's owncompatdata,shadercacheanddownloadingunderseats/<id>/are bind-mounted over the shared ones, so prefixes never cross. A seat other than the owner's sees an empty/run/media: Steam in Game Mode adopts every mounted drive with asteamappsas a library, and a drive as mounted is the owner's alone. -
The owner's libraries. At each start the daemon shows every library the owner's Steam lists (
steamapps/libraryfolders.vdf) atseats/<id>/libraries/<key>,<key>a hash of its path. Itssteamappsis the owner's own, through an idmapped mount: the seat's Steam plays, updates and runs Proton from it as the seat user, and every file stays the owner's on disk. The seat's owncompatdataandshadercachecover the owner's. A library is skipped, with a line in the daemon's log, when it is gone, itssteamappsisn't the owner's, or its filesystem takes no idmapped mount (anything but btrfs, ext4, xfs and f2fs). Withlibrary_writableoff (PUNKTFUNK_SEAT_LIBRARY_WRITABLE) the mount is read-only, and what a launch must write (common/SteamLinuxRuntime_*/var,common/SteamLinuxRuntime_*/*/files/.ref,common/Proton*/dist.lock) gets a private copy underseats/<id>/private/<key>. The owner's Steam settings are never changed. -
Granted roots. At each start every root the owner granted a plugin, as the library copy's
plugin-grants.jsonlists it, appears to the seat read-only at its own path, so a ROM the library names opens on the seat: idmapped on btrfs, ext4, xfs and f2fs, so the seat reads the owner's files as the owner, and a plain read-only bind elsewhere. A root in the owner's home is reached through an empty folder over that home. A root granted later reaches the seat at its next start.The mount rules, and the roots and libraries each skips, are in
crates/host/pf-seats/src/linux/libraries.rs. -
Save exchange. Each seat has
seats/<id>/saves/outandsaves/in, the seat user's, mode0770, with an ACL entry and a default one for the box owner's uid, so no other seat reaches them. The daemon makes them at each start, sets the owner's entries for the owner row it knows then, and keeps what is in them until the seat is deleted. At a launch of anemulatortitle, before its hold, the seat host exports every unit of that emulator and platform intoout/and lists them, with the platform's firmware, inout/units.json. Once the hold is released it puts back eachin/<name>beside anin/<name>.import.jsonfor that title, then emptiesin/; when the game exits it exports what changed. The owner's host answers the save routes'?profile=from there: it listsunits.json, copies a unit's file fromout/into the plugin's folder, and leaves an import inin/. It reads only plain files the seat user owns and never writes through a link. A seat's ROM roots are read-only, so nothing saves beside a game there. -
Stopping. The daemon's stop ends the seat's user (
loginctl terminate-user; a user still running after 10 seconds fails the stop withuser_busy) and takes back every pad relay and USB/IP socket it handed the seat. A stop of the owner's row ends neither: that account is a person's. -
Pads. A seat user can't open
/dev/uinputor/dev/uhid: they are the console user's. A seat host that can't write the node asks the daemon's pad broker,/run/punktfunk/pads.sock, for a pad by kind (pf_inject::pad_broker::Request). The daemon builds it from its own tables, keeps the kernel fd and hands the seat one end of a relay socket: a uinput pad's relay carries the seat'sinput_eventframes down and rumble back, and a uhid pad's carries theuhid_events the seat would write and read, minusCREATE2andDESTROY, which stay the daemon's. The pad lives until the seat hangs up. The socket admits a running seat's user only (the owner's row included), eight pads per seat. A box without the daemon gives a seat no pad. -
The fence. The daemon stamps each pad's
physpunktfunk-seat:…and records the device in/run/punktfunk/pads/vouched, root's alone.65-punktfunk-seats.rulesrunspunktfunk-seats fenceon every stampedinputandhidrawnode: a recorded device's node becomes that seat user's,0600, on a seat of its own, so neither the box's own Steam nor another seat sees it, and any other stamped node is root's. While the daemon runs, every other hidraw node keeps its group and seat0's ACL but losesotheraccess. -
/dev/uinput. Before a seat's unit starts, the daemon serves it a CUSE device,/dev/punktfunk/uinput-<user>(the user's,0600), and the unit binds it over/dev/uinputfor the seat's Steam Input. Each open is one real uinput device the daemon makes, 16 per seat. Only gamepad capabilities pass:EV_SYN,EV_KEYfor joystick and gamepad buttons,BTN_DPAD_*andBTN_TRIGGER_HAPPY*,EV_ABSwithout pressure, tilt, tool or multitouch axes,EV_MSC,EV_FFand the accelerometer property. Any other bit is refused withEPERM, so a seat never makes a keyboard or a mouse. It needs/dev/cuse;doctorreports it. -
USB pads. A seat's USB/IP pad (the virtual Deck, the Steam Controllers, the DualSense, the Switch 2) is served by the daemon, never by the seat. A seat that can't write vhci's
attachsends aServewith the pad kind, and the client pad's identity for a Steam Controller, whose serial passes as 1–16 letters and digits. The daemon starts a pad server,punktfunk-seats serve-usb, which answers every descriptor and control transfer frompf-inject's tables, records the vhci port in/run/punktfunk/pads/vhci/<port>and writesattach, four ports per seat. The seat gets one end of a relay and sends only input reports, by interface. Closing the relay ends the pad server and the device. -
Game Mode. The seat host keeps the seat's mode,
desktoporgame, inseat-sessionin its config directory, and starts in the one it ran last. It ownscom.steampowered.SteamOSManager1on the seat's session bus and servesSessionManagement1, whichsteamosctland Steam call;steamos-session-selectandsteamos-desktop-return, first on the seat'sPATH, call it too. A switch never reaches the box's login. On the owner's row only the headless host plays the seat's mode; the owner's own host at the desk leaves Game Mode, its bus name and its gamescope to the box. A seat never updates the box: the supervisor binds/run/punktfunk/seat-no-updateover each ofsteamos-polkit-helpers/steamos-update,steamos-polkit-helpers/jupiter-biosupdateandjupiter-initial-firmware-updatethe box has.
The owner's row and the door
On a box with Reachable without logging in on, the box's own host
is the door: punktfunk-host serve --door, run as the punktfunk user by
punktfunk-door.service with PUNKTFUNK_CONFIG_DIR=/var/lib/punktfunk. It advertises, pairs,
serves the management API and places every connect, and opens no display, capture, input or
audio device. Its primary group is punktfunk-door, its own: it is not in punktfunk, which owns
the usbip attach nodes and passes pf-dm-helper. The seat socket and the box's library copy are
that group's. door-helper ports and on move an account with any other primary group into it.
A connect is answered with a Redirect to a seat, or refused with
SEAT_UNAVAILABLE: the door never streams. GET /api/v1/host reports door: true. A host
started with PUNKTFUNK_SEAT_SESSION=1 is a seat host and never the door, however the door is
asked for.
A seat's seatless session may not take a logind block, so the door keeps the box awake: while
anyone plays on a running seat it holds a logind sleep block, which 49-punktfunk-door.rules
grants it. The screen at the desk can still go dark.
Every client reads its library from the door, which has PUNKTFUNK_LIBRARY_DIR set to the box's
library, /var/lib/punktfunk-seats/library: all of it, for every profile, read only. A cover
there names a file in the owner's home, which the door can't read, so it asks the owner's host
for it over that host's loopback API. While that host is down the cover answers 404; asking for
one never starts the owner's session.
The door answers only its own: the open routes, pairing (native/*), profiles/*, power
(actions/*), update/*, client-logs/*, its logs, events and events/relay, the library
copy's reads (library, library/page, library/art/*), and what it merges with the owner's host:
host (the owner's gamestream), host/settings (its own rows are host_name, mdns,
webtransport, webtransport_origins, the Seat defaults and update_*), status (every
seat's sessions, games and downloads, each row with seat, the profile whose host runs it, for
the operator) and diagnostics with diagnostics/refresh. Every other admin call goes to the
owner's host the way covers do, a dot segment never, and the table is checked against the API
document (every_route_has_a_side_on_the_door). The door releases a seat's launch hold on that
seat (game/holds/{token}/release, below). A device's game/end, GET display/targets,
PUT display/clients/* and GET compositors go to the seat it plays on, and its
library/install/* to the owner's host, whose plugins download into the shared game files, each
as that device: the door's call carries X-Punktfunk-Device, which a seat host honours only with
its admin token and only for a device paired there, and then answers on the device's grants. A
person's calls go to the owner's host too, once the door found the person. A
call starts a stopped seat and answers 503, except host, status and diagnostics, which
wake nobody; a host that doesn't answer is 502, and so is every call while the seat supervisor
doesn't answer. With no owner row the owner's calls answer 503, and so does a host/settings
change to any row but the door's own: the door's files never stand in for the owner's.
events and session/*/pads stream.
Events cross the same way. Each running seat's events join the door's bus with seat naming
the profile whose host raised them, so the console sees sessions, games, library and plugin
changes where they happen. The door's own (pairing.*, access.*, host.*, action.*,
update.*) go to the owner's host's POST /events/relay, whose hooks run them in the
owner's session; the door runs no hooks. The two sets are disjoint, so nothing comes back
around. A knock (pairing.pending) wakes a stopped owner's session and reaches its host once
that host answers, within two minutes, a supervisor that doesn't answer meanwhile included;
any other door event a stopped owner's host misses.
A seat's game.* goes to the same route with its seat kept: the owner's plugins hear it, and
no host's hooks run on it. A seat's launch is held for the box's plugins, so its
game.launching carries a hold token, and the launch waits up to 130 seconds for
POST /game/holds/{token}/release. The door sends it once the owner's host has run the plugins
that hold the stage, whatever they answered, and at once when that host isn't running. An event
a seat host lists with a seat already is one a host relayed there, and is never taken back.
The door's console also accepts a console handoff signed with the owner's host's token, the one
the owner's own ctl console-url holds: the door keeps it in owner-handoff-key in its own
directory, level with the owner's row, and never a contract seat's, whose user can read their
own. The owner's row never stops for being idle: it runs the plugins and keeps the library. A
seat host whose binary an update replaced restarts once nobody plays on it, and its runner
starts the new one.
display-settings.json and hooks.json are the owner's host's alone: door-helper copies
neither, the owner's host writes the per-device overlays too, the supervisor copies the policy
with the library (library/), the door reads it there for its second-device rule, and each
seat's trust copy takes it from there.
host.restart on the door restarts the owner's host, then the door itself; both stop and
their units start them over.
The box owner is a seat like any other. adopt-owner <user> adds a ledger row for an ordinary
account that exists (uid 1000 up, outside 60001–65535 and below 2³¹, never a seat's), and only for
the one door-helper on recorded in /var/lib/punktfunk-seats/owner. Any other account, or any
while nothing is recorded, answers owner_not_recorded. Only root writes that file, so the door
can't name another account. The door reads it to switch off, so a supervisor that doesn't answer
never keeps the door on. Deleting the row never removes the account or its home. It takes a slot and
ports from the same pools, and the door places the owner profile and the profiles that share the
owner's desktop on it. Its environment leaves out
PUNKTFUNK_CONFIG_DIR, PUNKTFUNK_LIBRARY_DIR, PUNKTFUNK_HOST_NAME and PUNKTFUNK_GAMESTREAM:
the owner keeps ~/.config/punktfunk, the library their plugins write, and their own settings. Its
environment file has an ACL entry that lets the owner read it, so the owner's user punktfunk-host
moves to the row's ports at boot, logged in or not. The owner's own console,
punktfunk-web.service, doesn't start while that file exists: the door's console has the port. With
the owner's GameStream setting on, the owner's row is the box's Moonlight host, on the owner's own
pairings: the door can't redirect Moonlight, and a contract seat never serves it.
A browser that sets PROFILES is redirected to its seat's browser plane: the Redirect names
that port and carries the plane's certificate hash (field 8), which the browser pins and binds
its credential to. A running seat host follows the box's Seat defaults within seconds: its plane
comes up on its port, under the box's origins, and closes when the box stops streaming to
browsers. A seat host without Seat defaults follows its PUNKTFUNK_WEBTRANSPORT* variables.
- One owner host. The row's unit runs the owner's host in a
backgroundsession, on a private D-Bus, beside whatever the owner has on the monitor. The owner'spunktfunk-hostandpunktfunk-kde-sessionuser units stand down while the unit's runtime directory exists. - The monitor wins. The supervisor polls logind every 2 seconds. When the owner has a session on a seat that isn't the row's own, it stops the row's unit and starts the owner's user host. The owner's session is never ended.
- Switching.
PUT /api/v1/profiles/doorwith{"on": true}or{"on": false}answers 202, or 204 when the box is already so. The owner's host startspunktfunk-door-on@<user>.service, the doorpunktfunk-door-off@<user>.service; both rundoor-helper, which moves the box's files between the owner's~/.config/punktfunkand/var/lib/punktfunkand turns the units on or off. The console'sweb-session-keyandweb-session-epochmove with them (on SteamOS, itsPUNKTFUNK_UI_SECRETline), so a signed-in console stays signed in.onalso gives the door the owner'shost-settings.json, so the door's own rows start as the owner set them; beforeoff, the door sends the rows it stored to the owner's host, and answers 503 while that host starts. The user must be in thepunktfunk-updategroup, and a member switches the door for their own account only. The helper writes that account to/var/lib/punktfunk-seats/ownerfirst. It refuses to switch on while the file names another account, and to switch off for any but the one it names, which may have left the group since. A name whose account no longer exists names no one: a member's switch-on takes the door over, replacing the box's files with their own where they have the same file, and a switch-off for it leaves the box's files in/var/lib/punktfunk. Only agetentmiss on a box whosepasswdsources are all local counts; any othergetentstatus, or apasswdsource such assssorldap, refuses both. The helper and the owner's host askcheck-ownerfor these rules. A switch the helper refuses or abandons setsdoor_switch_failedon the starting host'sGET /host, and the console stops waiting. - The box's ports. The door binds the ports the owner's
host.envnames (PUNKTFUNK_MGMT_BIND,PUNKTFUNK_NATIVE_PORT), and its console binds where the owner's console did (PUNKTFUNK_UI_BIND, fromweb.envon SteamOS).door-helper portscopies those lines to/var/lib/punktfunk-seats/host.env, root's, before each door start, so an update or a later edit reaches the door. On NixOS the door also takes the two port keys fromservices.punktfunk.host.settings, and its console followsservices.punktfunk.web.bind.
A seat on a person's own account
A seat on the Linux door can run on an account the box already has: a family member's own login.
The console asks with seat: true, seat_account, a Linux user name, and seat_password on
POST /api/v1/profiles. It runs as the owner's row does, on a bus of its own, but in a session
of the unit's user class, which a shutdown at the machine counts as another user's session,
and it is a contract seat's host, not the owner's.
- Command.
adopt_usercarriesseat, ascreatedoes, plus the account'spassword, and answerscreated. The supervisor reads the account's uid itself. The password goes once through PAM'sloginstack, the onepunktfunk-seat@.serviceruns its unit under:pam_authenticate, thenpam_acct_mgmt, so a locked or expired account is refused. Nothing keeps it, andcredentialis refused for the row. A refusal's code iscredential_wrong,credential_must_change,account_locked,account_missing,account_not_ordinary,account_is_owner,account_taken,unlock_locked_outorpam_unavailable. - The check. PAM runs in a child,
punktfunk-seats pam-check <account>, with an empty environment and the password on stdin; libpam never loads into the daemon. A child that hasn't answered in 30 seconds is killed and the answer ispam_unavailable. Other commands don't wait on it, and the account's uid is read again before the row lands. - Attempts. After a failed check a device waits 2 s × 2^failures, and 15 minutes after the
fifth. Two failed checks for one account reach PAM in any 15 minutes, from every source together,
so a stack that locks after three, such as
pam_faillock, is never driven there. A refusal answersunlock_locked_outwith the wait in its sentence. A check that gave no verdict counts as failed. - Which accounts. An ordinary user, as the owner's row takes: uid 1000 up, outside
60001–65535 and below 2³¹, never a seat's, named by the owner's name rule, so
user@domainof a directory service passes. Not the box owner, by name or by uid, and not an account another row holds, by name or by uid. - The row. It has
adopted_uid, the uid read at adopt, and noowner. A start, the certificate read and the doctor refuse a row whose name now resolves to another uid. A stop or a delete still runs and reaches only the row's unit. - A guest on the account. Nothing on the account changes: no
useradd,usermodoruserdel, no group, no mode or owner of the home, and the daemon writes nothing inside it. The row's files are under/var/lib/punktfunk-seatsand/run: its environment file stays root's, and the host keeps its identity inseats/<id>/config, the person's0700, not in~/.config/punktfunk. A stop ends the row's unit and no other process of the user, their login and SSH included. A delete removes what the row made and gives the account back. - The monitor wins. While the person has a session on a seat, a start answers
account_at_desk, and a running unit ends within 2 seconds of their login. Nothing takes the row over: their ownpunktfunk-hostunits are not started or stopped. - What it does not take. The owner's
host.envand settings, the box's library as its own (it plays the box's, from its trust copy), the door, and the owner's user host. The trust copy is readable by the person alone. - Their runner. The seat's plugin runner is
punktfunk-scripting-seat@<seat id>.service, a user unit the scripting package ships, in the person's own user manager, which the seat's login session starts. The seat's host starts it whileplugin-runner-onis in its config directory, at each of its own starts and when the person switches it on, and stops it when they switch it off;seat-sessionstops it with the seat. Nothing enables it, and its roots drop-in is in$XDG_RUNTIME_DIR/systemd/user, so nothing of it lands in their home. It reads the seat's config directory, sees none of/var/lib/punktfunk-seatsbut what it binds and none of~/.config/punktfunk, runs no loose script, and starts only the packages their session installed (PUNKTFUNK_PERSON_PLUGINS=1). Where AppArmor restricts user namespaces it runs under the profilepunktfunk-scripting-seat. The seat's host never names their ownpunktfunk-scripting.
The person's own session
A Linux seat's own host, made or on a person's own account, runs as the seat's account, so what it writes into its config folder only that account and root read. The console password never reaches this lane.
- The token. At start the seat host writes 32 random bytes as hex to
person-tokeninPUNKTFUNK_CONFIG_DIR, mode0600, owned by its uid, in a folder it owns; it refuses a folder that is a link, another uid's, or open to group or others, and the lane stays closed. No copy list of the supervisor names the file. The owner's row, the door and a plain host write none and take no ticket. A Windows seat keeps its token in memory (below). - The ticket.
punktfunk-host ctl console-url, run as the person in the seat, signs<unix s>.<32 hex nonce>.<profile id>.<HMAC-SHA256 hex>overpf-person-session:v1:<unix s>:<nonce>:<profile id>with the token, and writes a page openinghttps://127.0.0.1:47992/_auth/person?ticket=<ticket>. - The session.
POST /api/v1/auth/person/sessionwith{"ticket"}, from loopback, answers{token, expires_at, profile}. The host takes a ticket at most 60 s from its stamp, once, for its own profile; otherwise 401person_ticket_refused, and 429person_ticket_waitfor a refused one after ten refusals in a minute. A good ticket opens whatever was refused before it. A session lasts 12 hours, 16 at most, and ends with the host or onDELETEwith its token as the bearer. A call with an expired one answers 401person_session_ended. - On the seat host.
Authorization: Bearer <session>, from loopback, reaches every route scopedprofile-dataorprofile-execandGET hostandGET status; anything else is 403. Anasnaming another profile is 403.GET auth/person/profileanswers the session's own profile from the seat's trust copy,{id, display_name, accent?, has_avatar}, andGET auth/person/profile/avatarits picture; any other lane gets 404. - On the box. The console sends
X-Punktfunk-Person-SessionandX-Punktfunk-Person-Profilewith no bearer, and?as=naming that profile, to the door (on Windows the box host) from loopback. The door asks the profile's seat hostPOST /api/v1/auth/person/verify{"token"}with its admin token, and takes the call only when the answer names that profile; any other call answers 401person_session_ended. Refusals are not counted, so no other local caller can close a profile to its own person. It reachesmgmt_routes::PERSONand the readsGET library,library/page,library/art/{id}/{kind},library/scanners,library/overridesandsession/settings, never for the owner.library/overridescarries the box's shared sets asbox_sets, which the person reads and points at but never changes, and the box's entries asbox_gamesandbox_sources, only for the titles and sources the profile's library lists. The owner's host takes the door's word withX-Punktfunk-Personand its admin token, as for a device's person. - What only it writes, on a person's own account.
PUT hooksand the custom-entry writes. The host marks each withperson: true, and runs only hooks and lists only own titles so marked: a file planted without the mark runs nothing. Aprepstep runs only when an own title the person wrote names it. Any other lane is refused, withhooks_own_account_personorlibrary_own_account_person, reads no hooks there, and reads a marked title as 404. - Their emulators. The person adopts a copy of their own (
POST emulators/{id}/adopt) and prepares their emulators (POST emulators/{id}/prepare, without afirmware_dir); every other lane getsemulators_own_account. An install, a removal, content and the save exchange stay refused for the person too, withemulators_own_account_yet. Hermir's adoption list is in the account's own data folder, where no other lane writes. - Their plugins. The person installs and removes plugins (
store/installfrom the built-in source alone,store/uninstall), switches their runner (store/runtime), reads a plugin page'sui-credentialand decides folder grants. Their plugins, on their own tokens, register, publish their titles (library/provider/*), ship logs and ask for folders. A store install puts the package onplugin-run/person-plugins.json, which the host writes and the runner reads; a removal takes it off. The runner starts only those, and the host reads manifests and mints credentials for those alone: a package dropped into the seat'splugins/any other way runs nothing. A provider's rows on the seat's shelf are markedperson: true. Any other lane is refused withplugins_own_account_person(plugins_own_accountwhile the person lane is closed) and readsstore/runtimeas not installed; a catalog source is refused to the person too, withplugin_own_account_unreviewed. ThepluginsCLI there only reads.
A Windows seat's person
A Windows seat host is SYSTEM, so no file is the seat account's alone. It keeps its person token in memory and hands out tickets in the same form through a pipe. The session, the box's half and the marks above are the same; on Windows the box host checks with the seat host itself.
- The pipe.
\\.\pipe\punktfunk-person-<WTS session id>, owned by SYSTEM, open to SYSTEM and to the account signed in to the seat's session, labelled medium integrity with no read or write up, refusing remote clients. A caller writesticket\n. The host reads the caller's token and answers one ticket line only when its user is that account, its session the seat's, its integrity medium or higher and it runs in no AppContainer; anyone else is hung up on. - The way in.
punktfunk-host ctl console-urlin the seat asks there, refuses a pipe SYSTEM doesn't own, and printshttps://127.0.0.1:47992/_auth/person?ticket=<ticket>;--openopens it, or/closed?why=refusedwhen no ticket came. The status tray's Open console runs that in a session whose person pipe exists. - What it reaches there.
GET host,GET status, their profile and picture, their hooks, their own titles (library/customand its art), the store but its sources,store/runtime,GET plugins, a plugin page'sui-credentialand folder decisions (plugin-access). Every other profile route is 403: that host would act as SYSTEM on what the call names. A hook the person saves names nohmac_secret_file(hooks_secret_file_person). An install from outside the built-in source is refused (plugin_seat_unreviewed): that store runs as SYSTEM, and it writes into the seat's ownplugins/alone. Emulators stay the box's.
A Windows seat's runner
- Who runs it. The seat's host starts
scripting-run.cmdas the account signed in to the seat's session, with no window, in a job only the host holds. It does so at each of its own starts and when the runner is switched on, whileplugin-runner-onis in its config directory. It ends when it is switched off, when the host ends, and at the seat's sign-out. No scheduled task and no stored password. A run that ends with an error starts again within a minute. - What that account reads. Each start gives it read on
plugin-token,plugin-run/plugin-tokens.json, the TLS pin,mgmt-endpointandplugin-run, read andFILE_WRITE_ATTRIBUTESonpluginsand the runner bundle, and Modify onplugin-state, whererunner.loglands. Switching off takes them back. It gets no ACE on any other folder. - Its sandbox. Each plugin runs in AppContainer
punktfunk.plugin.<id>.<first 8 of the seat id>and reaches the host on\\.\pipe\punktfunk-seat-<seat id>-plugin-<id>, open to the seat's account and that container, so nothing of it is the box's plugin's of the same id.~in a manifest is that account's profile. On a person's own account it runs the packages their session installed alone (PUNKTFUNK_PERSON_PLUGINS=1). - Who installs. On a made seat the operator and the person install, switch and decide
folders. On a person's own account the person alone does; any other lane gets
plugins_own_account_personand readsstore/runtimeas not installed.
Who opens a person's seat
The supervisor, not the door, decides which device gets onto a person's row. The door relays and
names the device by the fingerprint its QUIC handshake authenticated. Every command below answers
invalid_request for any other row, not_found for a missing one, and invalid_request for a
fingerprint that isn't 64 lowercase hex or a device name that isn't 1 to 64 printable characters.
A refusal is backend with <code>: <sentence>, the sentence the person reads.
unlock {id, device: {fingerprint, name}, proof}answersunlocked {seat, started}only while the session runs and the device is admitted;startedwhen this unlock started it.proofis tagged bykind:none: an admitted device rejoins a running session. Otherwiseunlock_required.password {password, stay_signed_in, enroll}: the attempt limits, then PAM (loginstack, auth then account) in thepam-checkchild, outside the supervisor's lock. On success a stopped session is started through the same path asstart, soaccount_at_deskand the other start refusals apply, then the device is present;stay_signed_insetsstay, andenroll({alg: "p256" | "rsa2048", public}) stores its key. A key that isn't a P-256 point on the curve or a DERRSAPublicKeyof exactly 2048 bits isinvalid_request, before PAM. Refusals:credential_wrong,account_locked,credential_must_change,pam_unavailable,unlock_locked_out, orcapacitywhen the row lists 64 devices.key {signature}: the device's enrolled key signed the live challenge (below), which this uses up whatever the outcome; then as after a password, with no PAM call. A wrong signature, a challenge used up, expired or never asked for, and a box certificate that no longer matches its pin answerproof_invalid. A failure counts against the device's limit alone, never the account's.
unlock_state {id, fingerprint}answersunlock_state {needs}:nonewhile the session runs and the device is admitted, elsekeywhen it has a key, elsepassword.challenge {id, fingerprint}answerschallenge {challenge, seat}: 32 bytes from the OS as 64 hex, valid 120 seconds and once, the device's only live one. A device with no key getsunlock_required. The key signs"punktfunk-seat-unlock-v1"‖ challenge ‖ the seat id's 32 hex characters ‖ the device fingerprint ‖ the box fingerprint, 152 bytes, with SHA-256: ECDSA P-256 as an ASN.1 DER signature, or RSA 2048 as a 256-byte signature, PKCS#1 v1.5 or PSS with a 32-byte salt.left {id, fingerprint}answersleft: the device disconnected, so its presence ends 600 seconds later unless it rejoins.sign_out {id, fingerprint}answerssigned_out: forgets that device, its stay and its key, or every device without a fingerprint.devices {id}answersdevices {devices}, each{fingerprint, name, kind, added_unix, last_unix},kindkeywith a key, elsestay, elsefor_now.startof a person's row answersunlock_required, and autostart never starts one. The box host waits up to 210 seconds for anunlock.- The device list.
seats/<id>/devices.json, root0600. A password or key unlock makes the device present: no end while connected, 600 seconds afterleft, never more than 24 hours from the unlock, and none once the session stops, from a stop, the person's login at the machine or a unit found failed or down. An entry with neitherstaynor a key goes with its presence. A file the supervisor can't read, or one that holds anything it wouldn't write, is no device, anddoctorshowsdevice_list. - Who is admitted. A device that is present, or that stays signed in and has no key: a key
never lets a device on without a fresh proof, so a copied pairing key alone can't reach the seat.
The seat's
trust/<id>/punktfunk1-paired.jsonholds only the box's records of admitted devices, each as the box wrote it; the other trust files copy as on any seat. The box's file is the door's: one that is missing, unreadable, over 8 MiB or not{"clients": [...]}gives an empty list. The list is rewritten on each change of who is admitted and checked every 2 seconds while the seat runs. - The box pin. The first challenge or key proof pins the SHA-256 of the door's
/var/lib/punktfunk/native-cert.peminbox-pin. While the certificate differs from it, every challenge and key proof is refused anddoctorshowsbox_pinas an error; removing the file takes a new pin. - The audit line. Every
unlockwrites one journal line under the targetpunktfunk::audit:seat,account,uid,device,proof,result,device_failuresandaccount_failures. Never the password.
The row's environment is a contract seat's, with
PUNKTFUNK_SEAT_OWN_ACCOUNT=1 and PUNKTFUNK_CONFIG_DIR=seats/<id>/config. From it the runner
keeps the session on a private bus, leaves the user's other desktop processes alone and writes no
seat-only setup into their home, as PUNKTFUNK_SEAT_OWNER does on the owner's row.