a gate for guest users to access calls in matrix-rtc
  • Go 85%
  • HTML 14.7%
  • Dockerfile 0.3%
Find a file
Renovate 85afa4a037
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix(deps): update module github.com/jackc/pgx/v5 to v5.12.0
2026-10-31 08:02:15 +00:00
cmd/gate gate: rewrite admin UI with htmx, move implementation to internal/gate 2026-10-09 00:06:46 -06:00
internal ui: rebuild templates with css grid + rem units, mobile-first layout 2026-10-15 16:03:59 -06:00
.gitignore gate: always use native knock lobby 2026-10-08 18:10:26 -06:00
.woodpecker.yml element-call-gate: minimal Go guest-gate service 2026-10-07 20:24:56 -06:00
Dockerfile chore(deps): update golang:1.27 docker digest to 2920bb1 2026-10-29 08:02:39 +00:00
go.mod fix(deps): update module github.com/jackc/pgx/v5 to v5.12.0 2026-10-31 08:02:15 +00:00
go.sum fix(deps): update module github.com/jackc/pgx/v5 to v5.12.0 2026-10-31 08:02:15 +00:00
README.md Add Matrix widget admin authentication 2026-10-09 22:18:56 -06:00
renovate.json Add renovate.json 2026-10-09 08:01:31 +00:00

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 signed element_call_gateway cookie 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:

  1. Enable Publish to Directory so the room is intentionally discoverable.
  2. 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

  1. An operator opens /admin or 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 is POST /admin/invites with header Authorization: Bearer <GATE_TOKEN> and JSON body {"room_id": "!room:bvoy.dev", "expires_seconds": 1800, "max_uses": 1}. expires_seconds defaults to 1800 (30 minutes) and is capped at 24 hours; max_uses defaults 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}. The GATE_TOKEN itself never appears in this URL. The invite stops working the moment either limit is hit, whichever comes first: max_uses redemptions or expires_seconds elapsed.
  2. That url is the one shared with the guest (chat message, calendar invite, etc). It contains only an opaque, single-use invite code.
  3. 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.
  4. 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.
  5. The gate returns a minimal HTML page that writes the guest's session into localStorage under Element Call's matrix-auth-store key and redirects the browser into /room/#/guest?... on the standalone Element Call deployment. skipLobby is 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.
  6. 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} and POST /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/login and GET /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 by WIDGET_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 in WIDGET_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_TOKEN bearer credential (Authorization: Bearer <token> or X-Gate-Token: <token>) — always available, intended for scripts/CI/simple use; or
  • (if OIDC_ISSUER_URL is configured) an OIDC operator session, established by visiting /admin, authenticating with the identity provider, and optionally being required to belong to OIDC_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_TOKEN invalidates both the operator bearer credential and all active element_call_gateway/non-OIDC session cookies at once, since the token also signs them.