- Go 85%
- HTML 14.7%
- Dockerfile 0.3%
|
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
|
||
|---|---|---|
| cmd/gate | ||
| internal | ||
| .gitignore | ||
| .woodpecker.yml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
| renovate.json | ||
element-call-gate
element-call-gate is the application gateway for the standalone
Element Call site. It provides
invitation-only guest access to selected Matrix rooms while keeping Synapse
registration and administration private.
The Go service creates disposable Synapse accounts, joins them to an eligible
room, writes their short-lived Matrix session into Element Call's same-origin
matrix-auth-store, and hands the browser to Element Call. Guests never receive
a Synapse admin credential, and Matrix access tokens are not put in URLs.
Why
Element Call's built-in guest flow self-registers an account on the configured
homeserver. Homeservers that keep public registration closed (as is the case
here) reject that with 403 Registration has been disabled. This service
provides a controlled alternative: an internal caller mints a one-time
/invite/<code> link scoped to one eligible room, and the gate does the rest.
How it works
The production topology has two cooperating applications behind Traefik on the same origin:
- the gate owns
/,/invite/..., and/admin/...; - standalone Element Call owns
/room,/assets,/config.json, and/favicon.png; - those Element Call paths use forward authentication against
/auth/element-call, which validates the signedelement_call_gatewaycookie issued after invite redemption.
Keeping the iframe same-origin is intentional: the gate writes Element Call's
matrix-auth-store before loading the call UI. Element Call continues to own
MatrixRTC, LiveKit, media permissions, screen sharing, participant state, and
in-call controls.
Rooms opt in to guest calls through two settings that Sable exposes:
- Enable Publish to Directory so the room is intentionally discoverable.
- Set Room Access to Knock & Invite.
Sable writes m.room.join_rules with {"join_rule":"knock"} for the latter,
while directory publication is maintained separately through Synapse's room
-directory API. The gate requires both conditions; an ordinary public room or a
private/invite-only room is not eligible. No gate redeploy is needed to allow or
revoke a room; the gate re-checks both settings periodically
(ROOM_LABEL_CACHE_SECONDS, default 30s) via its admin account.
Note that eligibility (directory + knock) is only an opt-in signal, checked
when an invite is created and again when it is redeemed. The gate mints the
disposable guest account but never joins or knocks it into the room itself.
Instead it hands the guest's session to Element Call, so Element Call's own
knock lobby (KnockLobbyView) takes over: the guest sees Element Call's
normal "Ask to join" screen, Element Call submits the native Matrix knock
(POST /_matrix/client/v3/knock/<room_id>, per the
Matrix spec)
using the guest's own session, and its "Waiting for invite" lobby watches for
the host's decision. The room host lets the guest in or rejects them from their
own ordinary Matrix client (Element, Sable, etc.) exactly as they would for any
other knocking user — the gate has no bespoke approval queue, polling endpoint,
or admin decision UI of its own; the entire knock/approve/join cycle is handled
by Element Call and Matrix directly.
This also means the gate's admin account no longer needs to be a member of every eligible room: it is only used for guest registration, room-directory lookups, and guest deactivation, not for force-joining guests into rooms.
Invite lifecycle
- An operator opens
/adminor calls the authenticated API. The browser admin UI loads eligible rooms into a dropdown, so operators do not need to copy room IDs manually. The API equivalent isPOST /admin/inviteswith headerAuthorization: Bearer <GATE_TOKEN>and JSON body{"room_id": "!room:bvoy.dev", "expires_seconds": 1800, "max_uses": 1}.expires_secondsdefaults to 1800 (30 minutes) and is capped at 24 hours;max_usesdefaults to 1 (single guest) and is capped at 100, for cases like sharing one link with a small group within the same 30-minute window. The gate checks room eligibility immediately and returns{"url": "https://call.bvoy.dev/invite/<code>", "expires_at": "...", "max_uses": 1}. TheGATE_TOKENitself never appears in this URL. The invite stops working the moment either limit is hit, whichever comes first:max_usesredemptions orexpires_secondselapsed. - That
urlis the one shared with the guest (chat message, calendar invite, etc). It contains only an opaque, single-use invite code. - Opening the link (
GET /invite/<code>) shows an HTML landing page with the room's display name and a field for the guest's display name. No Matrix concepts are shown to the guest. - Submitting the form (
POST /invite/<code>) atomically redeems the invite (rejecting reuse, expiry, or revocation), re-checks room eligibility, and only then:- registers a new, non-admin Synapse user via the shared-secret admin
registration API (
POST /_synapse/admin/v1/register) with a random username and password, leaving it unjoined: Element Call's own lobby performs the knock once the guest opens the call; - records the guest in Postgres with its expiry time, for TTL cleanup.
- registers a new, non-admin Synapse user via the shared-secret admin
registration API (
- The gate returns a minimal HTML page that writes the guest's session into
localStorageunder Element Call'smatrix-auth-storekey and redirects the browser into/room/#/guest?...on the standalone Element Call deployment.skipLobbyis never set, so Element Call always shows its own knock lobby ("Ask to join" then "Waiting for invite") and handles detecting the host's approval or rejection itself — the gate is not involved in that detection. - A background janitor, backed by Postgres (not memory), deactivates and erases guest accounts once their TTL expires, even across gate restarts. A guest who knocks via Element Call but is never approved is cleaned up the same way once their account's TTL elapses.
The gate never talks to Synapse's admin API from the browser, and the Matrix
access token is only ever transmitted inside a same-origin-written
localStorage value, never as a URL parameter. The guest handoff can replace an
existing Element Call login in that browser, so use a private/incognito window
when testing guest links.
HTTP endpoints
Public and guest endpoints:
GET /— gateway landing page.GET /health— health probe.GET /invite/{code}andPOST /invite/{code}— display and redeem invitations.GET /auth/element-call— forward-auth check for Element Call paths.
Operator endpoints require an OIDC session or the GATE_TOKEN fallback:
GET /admin— browser administration UI.GET /admin/loginandGET /admin/callback— OIDC login flow.GET /admin/rooms— eligible public/knock rooms for the room dropdown.GET /admin/invites— list invite records.POST /admin/invites— create an invite.POST /admin/invites/{code}/revoke— revoke an invite without deleting it.DELETE /admin/invites/{code}— permanently delete an invite record.POST /admin/cleanup— deactivate and erase expired tracked guests.
If WIDGET_ADMIN_ROOM_ID is configured, the admin UI is also available as an
Element widget:
GET /admin/widget— widget bootstrap page, embeddable only byWIDGET_ALLOWED_ORIGIN.POST /admin/widget/session— exchanges a widget OpenID Connect token for a short-lived admin session token, after checking the caller's membership and power level inWIDGET_ADMIN_ROOM_ID.
The admin UI supports room selection, invite creation, scheduled starts, copy to clipboard, revoke, delete, and invite listing. Deleting an invite does not deactivate a guest; guest cleanup is handled separately by expiry. Deletion refuses while a tracked guest still references the invite.
Configuration
All configuration is environment variables plus mounted secret files:
| Variable | Default | Purpose |
|---|---|---|
LISTEN_ADDR |
:8080 |
HTTP listen address |
SYNAPSE_URL |
http://synapse.lab-coms-ns.svc:8008 |
In-cluster Synapse client/admin API |
ELEMENT_CALL_URL |
https://call.bvoy.dev |
Public standalone Element Call origin |
SERVER_NAME |
bvoy.dev |
Matrix server_name |
GUEST_TTL_SECONDS |
3600 |
Guest account lifetime before deactivation |
ROOM_LABEL_CACHE_SECONDS |
30 |
How long room guest-call eligibility is cached |
REGISTRATION_SECRET_FILE |
/secrets/registration/REGISTRATION_SECRET |
Synapse registration_shared_secret |
ADMIN_TOKEN_FILE |
/secrets/admin/ACCESS_TOKEN |
Access token for a dedicated gate admin account |
GATE_TOKEN_FILE |
/secrets/gate/GATE_TOKEN |
Capability token for operator API endpoints; always available as a fallback even when OIDC is configured. It also signs gateway cookies until a separate session-signing key is introduced |
DATABASE_URL |
(required) | Postgres DSN for durable invites and guest tracking |
OIDC_ISSUER_URL |
(unset) | If set, enables OIDC operator login for /admin in addition to GATE_TOKEN |
OIDC_CLIENT_ID |
(required if OIDC enabled) | OIDC client ID |
OIDC_CLIENT_SECRET_FILE |
/secrets/oidc/CLIENT_SECRET |
OIDC client secret |
OIDC_REDIRECT_URL |
(required if OIDC enabled) | Callback URL, e.g. https://call.bvoy.dev/admin/callback |
OIDC_REQUIRED_GROUP |
(unset) | If set, only ID tokens whose groups claim contains this value may use /admin |
OTEL_EXPORTER_OTLP_ENDPOINT (and related OTEL_* vars) |
(unset) | Standard OpenTelemetry environment variables; if unset, tracing/metrics are no-ops |
WIDGET_ADMIN_ROOM_ID |
(unset) | Matrix room ID whose membership/power level authorizes the admin Element widget (/admin/widget); leave unset to disable the widget entirely |
WIDGET_ADMIN_POWER_LEVEL |
50 |
Minimum power level in WIDGET_ADMIN_ROOM_ID required to use the admin widget |
WIDGET_ALLOWED_ORIGIN |
(required if WIDGET_ADMIN_ROOM_ID set) |
The single Element client origin (e.g. https://chat.bvoy.dev) permitted to embed /admin/widget and /admin |
POST /admin/cleanup (GATE_TOKEN or OIDC session) immediately deactivates and
forgets every guest account whose TTL has already passed, returning
{"deactivated": <count>}. It exists so a daily Kubernetes CronJob can force
a sweep independent of the in-process minute-granularity janitor (which only
runs while the gate pod itself is up) and the sweep the gate also runs once
automatically at startup.
Operator authentication
Every operator-facing endpoint (/admin, /admin/invites*, /admin/cleanup)
accepts either:
- a
GATE_TOKENbearer credential (Authorization: Bearer <token>orX-Gate-Token: <token>) — always available, intended for scripts/CI/simple use; or - (if
OIDC_ISSUER_URLis configured) an OIDC operator session, established by visiting/admin, authenticating with the identity provider, and optionally being required to belong toOIDC_REQUIRED_GROUP.
The small HTML admin UI at /admin lets an operator select an eligible room,
create and schedule invites, list invites, copy links, revoke active invites,
and permanently delete invite records. It does not expose GATE_TOKEN to page
JavaScript when using OIDC. For simple deployments, a ?token= query parameter
is supported once; the token is moved to browser sessionStorage and the URL is
immediately cleaned up. Do not use that mode on a shared computer.
Admin panel as an Element widget
When WIDGET_ADMIN_ROOM_ID is configured, operators can add the admin panel
as a widget in their Element client instead of visiting /admin directly:
in a room, use "Add widget" → "Custom widget" and set the URL to
https://<PUBLIC_BASE_URL>/admin/widget.
Authorization for the widget is entirely independent of OIDC/GATE_TOKEN: a
widget iframe cannot complete an OIDC browser redirect, so instead the
widget proves the viewer's Matrix identity using matrix-widget-api's
OpenID Connect token capability (fulfilled by Element without any visible
redirect), and the gate checks that identity's membership and power level
in WIDGET_ADMIN_ROOM_ID directly against Synapse — only users joined to
that room with power level WIDGET_ADMIN_POWER_LEVEL or higher (default
50, i.e. moderator) are issued a session. The resulting session is a
short-lived (1 hour) signed bearer token scoped only to the admin API;
it grants no additional Matrix permissions. /admin/widget and /admin
are only embeddable by WIDGET_ALLOWED_ORIGIN; every other page keeps
frame-ancestors 'none'.
Scheduled calls
Invites can optionally include starts_at (RFC3339) when created via
POST /admin/invites. The resulting link can be shared ahead of time: visiting
it before starts_at shows a "this call hasn't started yet" page with the
scheduled time, and redemption is refused with 403 until that instant. The
invite's expiry is automatically extended to cover the scheduled start if
needed, so the link does not expire before the call begins.
Observability
The gate is instrumented with OpenTelemetry (traces + metrics), controlled
entirely by standard OTEL_* environment variables; with none set, all
instrumentation is a no-op. HTTP handlers are wrapped with otelhttp for
automatic request spans. Emitted metrics include invite creation/redemption/
rejection (by reason), guest registration/deactivation (by result), cleanup
sweep count and duration, active-guest count, rate-limit rejections, and
operator auth failures (by mechanism). This service does not define alerts; that
is left to Grafana/Prometheus on top of the exported metrics.
Rate limiting
In-memory per-key token-bucket rate limiting protects:
- invite redemption (
POST /invite/{code}), keyed by client IP; - invite creation (
POST /admin/invites), keyed by the operator's bearer credential (or IP as a fallback).
Because limiter state is in-memory, this assumes a single gate replica (see Known limitations).
Deployment and security model
The service is intended to run as a single non-root, read-only container inside the cluster. Synapse registration secrets, the dedicated gate-admin access token, the operator fallback token, and the Postgres DSN are mounted or injected by Kubernetes secrets; they are not browser configuration.
The public ingress does not expose Synapse admin endpoints. Synapse is used
through cluster-internal APIs only. Element Call application paths are
separately protected by forward authentication, so navigating directly to
/room without a valid redeemed gateway cookie is rejected.
Known limitations (evaluation-grade, not yet production-hardened)
- Rate-limiter state is in-memory, so it does not work correctly across multiple gate replicas; keep the gate at one replica, or move limiting to a shared store before scaling out.
- Anyone able to change the room's directory visibility or join-rule setting (by default, room moderators/admins according to room power levels) can opt it into guest calls. Rooms that should never allow guests should not be both publicly listed and set to Knock & Invite.
- All invites rely entirely on Element Call's own knock lobby and the host's ordinary Matrix client to detect and act on approval or rejection; the gate has no visibility into or control over that exchange once it has handed the guest their session.
- A guest who knocks via Element Call but is never approved (or rejected) is still cleaned up like any other guest once their account's TTL expires; their knock is simply abandoned on the Matrix side.
- Rotating
GATE_TOKENinvalidates both the operator bearer credential and all activeelement_call_gateway/non-OIDC session cookies at once, since the token also signs them.