Diagnose reachability for the request that called it - certificate, origin, secure context.
const url = 'http://localhost:8080/v1/admin/access-check';const options = {method: 'GET', headers: {Authorization: 'Bearer <token>'}};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}use reqwest;
#[tokio::main]pub async fn main() { let url = "http://localhost:8080/v1/admin/access-check";
let mut headers = reqwest::header::HeaderMap::new(); headers.insert("Authorization", "Bearer <token>".parse().unwrap());
let client = reqwest::Client::new(); let response = client.get(url) .headers(headers) .send() .await;
let results = response.unwrap() .json::<serde_json::Value>() .await .unwrap();
dbg!(results);}curl --request GET \ --url http://localhost:8080/v1/admin/access-check \ --header 'Authorization: Bearer <token>'§S6b. Turns three invisible failures into three sentences, for THIS request: the Host and Origin seen, whether that host is covered by the served certificate’s SANs, the fingerprint and days-to-expiry, whether the origin would pass the /v1/signal allow-list, and whether that allow-list is configured at all.
TOPOLOGY DETECTION (§S6-0). When the request arrives with X-Forwarded-Proto or RFC 7239
Forwarded, a proxy is in front of this control plane, so its certificate is an internal
detail the browser never validates: certificate.in_use is FALSE with a reason saying
the setup is supported and complete, and the answer focuses entirely on origins.
Telling an operator to fix something already correct is the failure mode this endpoint
exists to prevent.
FORWARDED HEADERS SOFTEN ADVICE AND NOTHING ELSE. They are trivially spoofable by any client; the worst a hostile one achieves is making an admin-gated diagnostics page tell THEM that their own certificate is not in use. No access decision, on this route or any other, reads them.
Admin-gated by the existing RequireAuth -> RequireAdmin middleware. It reflects the Host and Origin headers, so both are LENGTH-CAPPED (256 chars) and are only ever rendered as JSON string values - a client must never put them through dangerouslySetInnerHTML. It discloses configuration an admin can already read.
Authorizations
Section titled “Authorizations”Responses
Section titled “Responses”OK.
object
object
The Host header seen, LENGTH-CAPPED at 256 characters (a truncated value is suffixed). Reflected input - render as text, never as HTML.
The Origin header seen, or “” when the client sent none. Same cap and same rule as host.
What THIS control plane observed on the wire - https when the request arrived on the TLS listener. Observed, never taken from a header.
The protocol an upstream proxy CLAIMS the browser used (X-Forwarded-Proto, or the proto parameter of RFC 7239 Forwarded). A CLAIM, not a fact - it may soften advice and authorises nothing. Absent when no proxy header was present.
§S6-0 topology C: a proxy is in front of this control plane. When true the certificate section reports in_use=false and a client MUST NOT present the internal certificate as a problem.
Whether the browser will treat the page as a secure context - the precondition for getUserMedia (the microphone, §S7) and Keyboard Lock. True for https to this listener or https to an upstream proxy. THE LOOPBACK EXCEPTION IS TAKEN FROM THE BROWSER’S ORIGIN, NEVER FROM THE HOST HEADER once a forwarded protocol shows a proxy is in play: a proxy passing to 127.0.0.1 rewrites Host to a loopback authority, which would otherwise report an insecure public page as secure. That false positive is expensive out of proportion - it tells the operator the microphone should work and sends them hunting a bug that is not there. This is the link between the certificate story and the microphone story, and why they belong on one panel.
object
False under topology C, and false when QUASAR_TLS=off. Both are supported, complete configurations.
Operator-facing prose stating that the setup is supported and what to look at instead. Present only when in_use is false.
The PUBLIC metadata of a certificate. Every field here is already disclosed by any TLS handshake with this listener. THERE IS DELIBERATELY NO FIELD THAT COULD HOLD KEY MATERIAL, and adding one would be the bug this shape exists to prevent.
object
Self_signed = the batteries-included pair generated into QUASAR_TLS_DIR on first boot; provided = files mounted at QUASAR_TLS_CERT/QUASAR_TLS_KEY. (A third value, uploaded, is reserved for the §S6d upload route and is not emitted by any server that does not serve it.)
SHA-256 over the DER, uppercase colon-separated hex. The value to compare OUT OF BAND against the control-plane startup log before trusting a self-signed certificate.
Recomputed on every read, never served frozen at install time. This is the number that catches the 90-day Let’s Encrypt trap, and a stale one would be worse than none. Can be negative for an already-expired certificate loaded from disk (upload refuses one).
Leaf plus any intermediates supplied with it.
The leaf issued itself - true for the batteries-included pair, and the reason a browser warns.
Whether the request’s Host is covered by the leaf’s SANs. Present only when in_use is true. A leaf with no SANs covers NOTHING - the legacy Common Name fallback is dead in every current browser, so reporting CN coverage would tell an operator their setup is fine while their browser refuses it.
The remedy, when there is one, in operator language. ORDERED BY WHAT BLOCKS THE OPERATOR, VALIDITY FIRST: already-expired, then near-expiry, then SAN mismatch, then self-signed, with NOT-YET-VALID ahead of all of them - a post-dated or expired certificate cannot be rescued by trusting it or by correcting its names, and both are reachable because the on-disk compatibility path deliberately keeps serving such a pair rather than refusing to boot. Also SOURCE-SPECIFIC: the fix for a SAN mismatch depends entirely on where the certificate came from, so this branches on info.source. For self_signed it names the QUASAR_TLS_HOSTS value AND the fact that the certificate is NEVER regenerated when that variable changes (delete the pem files and recreate the container) - the single most expensive unstated fact in this area. For provided it says QUASAR_TLS_HOSTS does not apply and the mounted file must be re-issued. A single remedy would send operators of the other supported topologies down steps that cannot work.
object
Whether any allow-list entry is in force. FALSE IS A NORMAL, WORKING STATE - see same_origin_exemption.
Which source won. environment means QUASAR_ALLOWED_ORIGINS is SET and the admin-editable column is not consulted; a UI should grey out its editor and say so.
The RESOLVED list /v1/signal actually enforces.
Whether THIS request’s Origin would pass. Null when the request carried no Origin header at all (a non-browser client, which /v1/signal admits).
True when the origin passes ONLY because it matches Host, not because it is listed. This is the case that silently breaks behind a Host-rewriting reverse proxy, and it is why “always seed the IP as an allowed origin” would be a no-op that looks load-bearing - a same-origin request already passes with NO configuration.
The current origin, normalized and ready to add, when it would otherwise be refused - detection plus the fix, not detection alone. Empty when there is nothing to suggest, and deliberately empty when the list is pinned by the environment, because adding it in the UI would do nothing.
Example
{ "request": { "scheme": "http" }, "certificate": { "info": { "source": "self_signed" } }, "origins": { "source": "environment" }}Missing/invalid/expired/revoked token.
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}Authenticated but insufficient role / not the owner (precedes resource lookup).
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}