detain / phlix-hub
Central cloud directory + reverse-tunnel relay for Phlix media servers. Sign in once, reach any of your servers from anywhere. Self-hostable.
Requires
- php: ^8.3
- crell/tukio: ^2.0
- detain/phlex-shared: ^0.4.0
- monolog/monolog: ^3.0
- php-di/php-di: ^7.0
- psr/container: ^2.0
- psr/event-dispatcher: ^1.0
- smarty/smarty: ^4.0
- workerman/mysql: ^1.0
- workerman/workerman: ^5.0
Requires (Dev)
- mockery/mockery: ^1.6
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.0
- squizlabs/php_codesniffer: ^3.10
- vimeo/psalm: ^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-03 22:17:56 UTC
README
Central cloud directory + reverse-tunnel relay for Phlix media servers. Sign in once, reach any of your servers from anywhere — no port forwarding, no static IP, no VPN. Fully self-hostable.
Table of contents
- What is the Hub?
- Features
- Terms used in this document
- Architecture
- Requirements
- One-line install
- Updating an existing install
- Backing up and restoring
- Uninstalling
- Running alongside phlix-server
- Quick start (development)
- Production install on Ubuntu
- Docker
- Configuration reference
- Database schema
- HTTP API
- Watching with family and friends
- Connecting a media server
- Federation: pairing two hubs
- Portal admin basics
- Troubleshooting
- Testing & quality
- Project structure
- Related repositories
- License
What is the Hub?
A Phlix media server normally lives on your home network behind NAT. The Hub is the small, self-hostable cloud service that makes those servers reachable from anywhere:
- Each server claims itself to the Hub once (a short pairing code), then holds an outbound WebSocket reverse tunnel open to the Hub.
- Remote clients (apps, browsers) connect to the Hub, which relays their traffic down the tunnel to the right server — so the server never needs an inbound port open.
- The Hub is also a directory: it tracks which servers you own, their liveness (heartbeats), shared libraries, invite links, and media requests.
The Hub is self-hosted software: you run it on a machine you control, for your servers. This repository documents that deployment only — there is no vendor-run public instance pointed at from here.
Do I actually need a hub?
Not always. The hub exists to defeat NAT without port-forwarding and to host the social layer. Skip it when either of those is already solved another way:
- Everyone watches on your home LAN → the Phlix apps can reach the server's own address directly; a hub adds nothing.
- You're willing to port-forward and run TLS yourself → remote clients can dial the server's own :8097 WebSocket listener directly (that socket needs no hub role in the middle) — no relay needed.
Run a hub when you want remote access without opening inbound ports (servers dial out to it), a directory of your servers with liveness, library sharing and invite links for people who don't have accounts on the server itself, media requests (Sonarr/Radarr fulfilment), or hub-to-hub federation.
The step-by-step user journeys — what-the-hub-is, remote-access, self-hosting, claiming a server, library sharing, sharing with friends, invite links, requests — live in the docs site: phlix-docs → Hub. This README is the operator/API reference; those pages are the illustrated path.
Features
- Accounts & auth — signup/login, Argon2id password hashing, HMAC-SHA256 JWT access &
refresh tokens, and a published JWKS endpoint. The first account created is auto-promoted
to admin — on a fresh deployment register your admin account before exposing
:8800to the network; whoever signs up first wins. Close the door afterwards withHUB_SIGNUPS_ENABLED=falseat boot or the liveauth.signups_disabledhub setting (admin console → Settings). - Server claiming — short-lived claim codes, enrollment JWTs, and a server registry with per-server public keys (JWK).
- Reverse-tunnel relay — servers hold an outbound WebSocket to the Hub; remote clients are multiplexed down that tunnel over a compact binary frame protocol. Idle tunnels are reaped and liveness is tracked with heartbeats. The tunnel data-plane applies real backpressure: when the send buffer is full, the upstream reader is paused and resumed only when the buffer drains, preventing silent frame drops on slow streams.
- Subdomain allocation — each enrolled server can be assigned
<subdomain>.<public-domain>for clean, per-server URLs. - Library sharing — share a specific library on one of your servers with another Hub user, with read-only or read/write permission levels. The Shares page carries the direct door (share instantly with a registered collaborator) above the list-and-revoke view; for people without an account yet, Invite Links create the account and the share in one step.
- Invite links — single-use, signed invite links that grant library access to a recipient.
- Hub-to-hub federation — federate with peer hubs to share libraries across hubs, with
per-peer sessions and admin delegation. Honest limit: delegation is currently local
bookkeeping only —
createAdminDelegationwrites the DB row plus an audit entry, but the master-side wire push is not implemented, so noadmin_delegationframe ever reaches the peer today (the reserved envelope is documented as such on:8805in the OpenAPI spec). - Watch Party (SyncPlay relay) — watches in lockstep across devices: the hub carries the
synchronised-playback messages (play/pause/seek) for viewers outside your home
network, with a Watch Party page at
/app/watch-partyfor hosting and joining rooms. Account-scoped today — see Watching with family and friends for what that means before you invite anyone. - Media requests — a Jellyseerr-class request queue. Users request movies/series; admins approve, and the Hub talks to Sonarr/Radarr to fulfil them.
- Web UI & admin console — the hub's front door is a Vue SPA (the shared
@phlix/uidesign system) served at/app(/redirects to/app/servers). Every signed-in user gets My Servers, Server Detail, Search, Shares, Shared With Me, Watch Party, Invite Links, Requests, Security (own password/sessions), MCP tokens (own API credentials), and the media-browsing surfaces (music, books, audiobooks, photos) for the selected server; there is a public invite-acceptance page at/app/invite/:token(the target of the publicGET /invite/{token}link). Federation, Federation Shares, and therequiresAdminadmin console at/app/admin/*(Hub Dashboard, Users, Logs, Settings, Audit Logs, request approval) are admin-only — the nav hides them for members and every/api/v1/admin/*endpoint gates server-side regardless. See Portal admin basics for a page-by-page guide. The Vue SPA is now the only web UI: the legacy server-rendered (Smarty) pages have been deleted and their old paths 302-redirect to/app(e.g./my-servers→/app/servers,/login→/app/login, the public/invite/{token}→/app/invite/{token}). Thesmarty/smartydependency was removed — the hub renders no templates. Everything is backed by a full JSON API under/api/v1(incl./api/v1/admin/*). - MCP server — an MCP endpoint at
/mcp(JSON-RPC overPOST, SSE overGET) exposing read-only tools over the servers you own. Authenticated with scoped personal access tokens minted at/api/v1/me/mcp-tokensand stored only as a SHA-256 hash. - OAuth 2.0 authorization server —
/oauth/authorize,/oauth/token, and the scoped resource surface/oauth/userinfo, with S256-only PKCE and a scope vocabulary shared with MCP. Clients are registered from the CLI (php bin/phlix oauth:client:register). - Alexa skill — an account-linked skill endpoint at
/alexa/skill, gated by full Amazon signature + certificate-chain verification, that drives playback on your servers by voice. - Operations-ready — structured JSON logging (Monolog) across dedicated channels
(app, error, hub, relay, audit), a
/healthendpoint, and idempotent SQL migrations.
Terms used in this document
Plain definitions for the vocabulary this README leans on — the jargon wall a reader hit otherwise. Also see the model case: "claim code" is glossed inline wherever it first appears.
- SPA — single-page application: the Vue app under
/appthat loads once and redraws itself client-side; the hub's entire web UI. - JWT — JSON Web Token: a signed, self-describing credential string. Three
kinds live here, and calling all of them "token" is the estate's worst habit:
- session JWT — your login credential; the portal holds it and calls
/api/v1with it. - enrollment JWT — issued to a media server ONCE, at claim redemption;
the server presents it in
HELLOon its :8802 tunnel to prove who it is. - relay token — a short-lived, per-session bearer a client uses to join
:8803/:8804; carried in the WebSocket subprotocol (
["bearer", <tok>]), never in a URL. - (A fourth "token" — the invite token — is the secret in
/invite/{token}URLs; redeeming it is one-time.)
- session JWT — your login credential; the portal holds it and calls
- claim code — the human-typed
ABCD-1234pairing handoff (see Connecting a media server). - campaign markers — codes like
S261,R-HUB-13,M-HUB-10,HB-4.6,R14-08, bare commit SHAs, or "review hub__asphp M2" are provenance tags from this project's tracked review/audit campaign (each names a finding, requirement, or decision, not a public issue link). They say why a rule exists; the code and behavior they reference is what ships. - watch party / group / room — one concept, three names, by layer (the
naming-layers map phlix-server's README publishes, carried here): the
canonical wire says group (
syncplay_group_*frames,group_id); the hub internals say room (SyncPlayRelayWorker'sroom_state,MAX_ROOM_MEMBERS,broadcastToRoom, and the legacy bare-room dialect); member-facing copy says watch party.docs/websockets.mdkeeps the wire/internal spellings; the portal shows members only the party copy. - federation — hub-to-hub linking: two independent hubs share libraries with each other. Not a global network; strictly pairwise and consented.
- peer — the other hub in a federation link.
- master / leaf — the two roles of one federation link: the leaf dials (initiates the outbound connection), the master accepts. A hub may be master on some links and leaf on others.
- dial — to open an outbound WebSocket connection to another service (the hub never dials into your media server; your server dials the hub).
- JWKS — JSON Web Key Set: the hub's public-key list at
/.well-known/jwks.json, how peers verify each other's signatures without shipping private material. - NAT — Network Address Translation: why home servers have no public inbound address, and therefore why every persistent connection here is dialed OUTBOUND.
- SNI — Server Name Indication: the hostname a TLS client states mid-handshake so one endpoint can serve many certificates.
- HLS — HTTP Live Streaming: the segmented
.m3u8+.tsplayback format the relay fronts for remote viewing. - PKCE — Proof Key for Code Exchange: the OAuth 2.0 extension that stops
an intercepted authorization code from being redeemed; this hub accepts
S256only. - backpressure — pausing a fast sender when the receiver's buffer fills, instead of buffering forever or dropping bytes.
- reaped — force-closed by the liveness loop after missing keepalives (30 s ping / 90 s window on the relay lanes).
- seam — a deliberate configuration junction where behavior swaps without
a code change (e.g.
HUB_WS_URLis the seam between direct-:8804 dialing and proxy-frontedwss://).
Architecture
The Hub runs as a set of long-lived Workerman workers in a single process group:
| Worker | Default port | Purpose |
|---|---|---|
| HTTP | 8800 |
REST API + the Vue SPA (/app; legacy page paths 302-redirect here) + /health |
| Relay (server-facing) | 8802 |
Servers connect here to open their outbound tunnel |
| Relay (client-facing) | 8803 |
Client-mount WS (GET /client/{server_id}). ⚠ Declared unavailable in the multi-process topology: every mount is refused with close 1011 (client_mount_bridge_unavailable) — see docs/websockets.md; live client↔server traffic rides the HTTP relay proxy (/api/v1/servers/{id}/proxy/*) |
| SyncPlay relay | 8804 |
Synchronised-playback clients (GET /syncplay/{server_id}) |
| Federation | 8805 |
Hub-to-hub peer tunnel |
Every WebSocket surface is documented in docs/websockets.md; the :8800
HTTP surface is described by openapi.yaml.
Supporting pieces:
- PSR-11 container (PHP-DI 7) wires services; routes are registered in
src/Application.php. - MySQL is accessed through a coroutine connection pool (
workerman/mysql, a mysqli wrapper; each coroutine leases its own connection — seeDB_POOL_ENABLEDin the configuration reference). The pool is initialised lazily so/healthstays up even if the database is briefly unreachable. - JWT auth is symmetric (HS256); Ed25519 keys are used for signing enrollment/relay material,
and the public key set is served at
/.well-known/jwks.json.
Requirements
- PHP 8.3+. Nine extensions are hard gates —
openssl,curl,sodium,mbstring,json,hash,filter,ctype,posix— asserted byscripts/assert-required-extensions.php(each entry citing the exact symbol the code calls). Be precise about WHERE the gate bites: it runs as a CI step in every PHP job (a build with a missing extension never ships), and the managed installer provisions them via the apt package list (php-cli php-mysql php-mbstring php-curl …) and the container base image carries them. The hub process does not re-check them at boot: a hand-assembled host missing e.g.curlstarts and then fails at first symbol use (Call to undefined function openssl_verify()inside the Alexa signature gate presents as rejected signatures, not as a clear config error). On any install path other than the managed installer, install them yourself.opensslis additionally what makes any TLS dial (federationwss:///https://masters, relay TLS) possible at runtime — without it those paths fail loud, not silent. pcntl— not boot-gated (Workerman requires it for process management rather than the hub source calling it directly; it ships with Ubuntu'sphp-clipackage).- Swoole + php-uv are recommended, not required. They enable Workerman's coroutine runtime;
the hub runs without them but logs an explicit
Swoole extension not detected — coroutine runtime will not be activewarning at boot.scripts/install.shcompiles both from source; the manual package list below deliberately omits them (apt has no php-swoole on the supported releases) — build them or useghcr.io/detain/phlix-base, which carries PHP 8.3 + Swoole + php-uv. - MySQL 8.0+ (or MariaDB 10.6+)
- Composer 2
- A POSIX host (Linux recommended; Workerman uses
pcntl/posixfor process management)
One-line install
On a fresh Ubuntu/Debian host, scripts/install.sh does everything in
Production install for you — system packages, MySQL database +
user, code, env file, firewall decisions, JWT secret, migrations, a systemd service, and an HAProxy reverse proxy
with an auto-renewing Let's Encrypt certificate:
The installer also compiles the Swoole + php-uv extensions from source (the coroutine runtime Workerman uses), idempotently skipping the build when they already load, and runs a
disable_functionspreflight — see Swoole & php-uv on Linux.
curl -fsSL https://raw.githubusercontent.com/detain/phlix-hub/master/scripts/install.sh | sudo bash
On a production host, pin what you pipe into root.
masterfloats — the bytes you execute today need not be the ones you reviewed. The published release tags are the audited states; fetch one with--branch <vX.Y.Z>(see Install flags — the update path uses the same form), or download, inspect, then run locally:curl -fsSL <the same URL> -o /tmp/phlix-install.sh && less /tmp/phlix-install.sh && sudo bash /tmp/phlix-install.sh. Day-one convenience is fine for throwaway boxes; on a box holding user credentials, a pinned script is the difference between an install and an open root-shell for whoever ownsmasterthat day.
To set up HTTPS at the same time, pass your domain and an email for Let's Encrypt:
curl -fsSL https://raw.githubusercontent.com/detain/phlix-hub/master/scripts/install.sh \
| sudo bash -s -- --domain hub.example.com --admin-email you@example.com
The script prompts for the install path, database user/password, and hostname when run in a
terminal (with sensible defaults), and runs fully unattended when piped or given -y. See
sudo bash scripts/install.sh --help for every flag. Prefer to do it by hand? Follow the
step-by-step guide below.
Install flags
sudo bash scripts/install.sh --help lists every option. The most useful:
| Flag | Effect |
|---|---|
--domain HOST |
Public hostname for the hub (enables TLS when paired with --admin-email) |
--admin-email EMAIL |
Email registered with Let's Encrypt |
--db-name, --db-user, --db-pass, --db-host, --db-port |
MySQL identity (random password if --db-pass omitted) |
--jwt-secret SECRET |
HMAC secret used to sign JWTs (random 32-byte hex if omitted) |
--service-user USER |
System user to run as (default phlix-hub — dedicated system account, created if missing) |
--workers N |
HTTP worker processes (default 4) |
--branch NAME |
Git branch or tag to install (default master) |
--repo URL |
Git repository URL (default detain/phlix-hub) |
--tls / --no-tls |
Force or skip Let's Encrypt + HAProxy TLS |
--no-proxy |
Skip the managed HAProxy entirely (use your own reverse proxy) |
--update |
Pull new code + run migrations on an existing install (preserves env + secrets) |
--uninstall |
Remove the install — interactive prompts before each destructive step |
--purge |
With --uninstall, also drop the DB, delete the Let's Encrypt cert, and remove the dedicated system user |
-y, --non-interactive |
Never prompt; use defaults/flags |
--interactive |
Force prompts even when piped |
Default service user changed from
www-datatophlix-hubso the hub runs under its own dedicated system account, isolated from the apache/nginx-ownedwww-data. Existing installs that were created onwww-datakeep running onwww-data—--updatereadsUser=from the systemd unit rather than rewriting it.
Updating an existing install
The same scripts/install.sh updates an in-place install without rotating any secrets. It
reads the existing /etc/phlix-hub.env (so the JWT secret and DB password are preserved),
pulls the latest code, refreshes Composer dependencies, runs pending migrations, and restarts
the service:
Updating the hub's web-UI assets (@phlix/ui re-pin + Vite bundle rebuild under
public/assets/app/) is a separate procedure — the runbook for it lives in
docs/ui-asset-rebuild.md.
sudo bash /opt/phlix-hub/scripts/install.sh --update -y
Or via the one-liner:
curl -fsSL https://raw.githubusercontent.com/detain/phlix-hub/master/scripts/install.sh \
| sudo bash -s -- --update -y
Pin to a specific tag or branch with --branch:
sudo bash /opt/phlix-hub/scripts/install.sh --update --branch v1.0.0 -y
What --update does, in order:
- Discovers the install path from the systemd unit's
WorkingDirectory(so non-default--install-pathsetups are detected automatically). - Reads
/etc/phlix-hub.envand reuses every value —HUB_JWT_SECRET,HUB_DB_PASSWORD,HUB_PUBLIC_DOMAIN, etc. are never regenerated. git fetch --depth 1 origin $BRANCHthengit reset --hard origin/$BRANCHin the install directory. Uncommitted local edits are discarded — the script warns first.composer install --no-dev --optimize-autoloader(followscomposer.lock).- Clears the legacy
var/smarty/{compile,cache}directories only if they still exist (vestigial from the pre-SPA Smarty UI — a no-op on installs created after it was removed). - Runs
scripts/run-migrations.php— idempotent, only pending migrations apply. systemctl daemon-reloadthensystemctl restart phlix-hub.curl http://localhost:$HUB_PORT/healthas a final sanity check.
What it explicitly does not touch: the env file, MySQL grants, HAProxy config, or the
Let's Encrypt certificate. If a release adds new HUB_* env vars, append them to
/etc/phlix-hub.env yourself — anything the code expects but doesn't find falls back to its
documented default.
Backing up and restoring
The Hub has exactly three pieces of state. A backup that does not carry all three together is not a backup — restoring a database against a different secret/key pair (or a fresh install's auto-generated key) produces a hub that starts cleanly and then fails closed in ways that look like data loss.
| Piece | Where | What breaking it costs |
|---|---|---|
| Database | MySQL schema phlix_hub (credentials in /etc/phlix-hub.env) |
Every server registration, user, claim code, invite, share, quota row, federation peer/key registration, rate-limit history, audit trail and the migration ledger. Restore only onto a schema migrated to the same or a newer code version (the migrations table checksums files; see 6. Run migrations in the production install below). |
| JWT session secret | HUB_JWT_SECRET inside /etc/phlix-hub.env (chmod 600) |
Signs every access/refresh token (config/auth.php; ≥32 bytes). If it is lost, changed, or the env file is restored from a different era, every issued session becomes unverifiable: all users are logged out at once and refresh tokens cannot save them. When the var is missing entirely, AuthServicesProvider falls back to a process-local random secret — tokens die on every restart, so never run production without it. |
| Hub signing key | config/hub-signing-key.pem, mode 0600, auto-generated on first boot by HubServicesProvider → Ed25519KeyManager (path override: add 'hub_signing_key_path' => '<absolute path>' to the array in config/server.php — the shipped file does not define the key, and HubServicesProvider.php:179 reads it from there, so the override is an operator edit, not an env var) |
The hub's Ed25519 identity. It signs enrollment artifacts servers present on pairing and is the master-side key in the federation mutual proof-of-key ceremony. If it is lost and a fresh one is generated, already-registered servers fail verification (re-pair them) and federation peers whose leaf keys were registered under the old identity refuse the hub until trust is re-established with each peer. |
Everything else is derived and safe to lose: .logs/ and var/ (Smarty
compile/cache, DI container cache, Swoole HOME lock, Workerman pid/status).
public/assets/app/ is a committed build — recover it from the repository,
not from a backup.
Backup (cron-friendly, keeps the trio in one archive):
#!/usr/bin/env bash set -euo pipefail STAMP=$(date +%F_%H%M) DEST=/var/backups/phlix-hub/$STAMP; mkdir -p "$DEST" # 1. Database (consistent snapshot without locking tables). mysqldump --single-transaction --routines phlix_hub | gzip > "$DEST/hub-db.sql.gz" # 2. Environment (contains HUB_JWT_SECRET + DB credentials) and 3. signing key. install -m 600 /etc/phlix-hub.env "$DEST/phlix-hub.env" install -m 600 /opt/phlix-hub/config/hub-signing-key.pem "$DEST/hub-signing-key.pem" find /var/backups/phlix-hub -maxdepth 1 -type d -mtime +14 -exec rm -rf {} +
Restore, in the order that keeps every boot gate happy:
-
Stop the service (
systemctl stop phlix-hub). -
Create the schema and reload — the dump is gzipped, so decompress into the client; feeding the
.gzbytes directly intomysqlfails with a syntax error and leaves an empty schema:gunzip -c hub-db.sql.gz | mysql phlix_hub -
Put
phlix-hub.envat/etc/phlix-hub.envbefore first boot (mode 600, owned by root — the service reads it viaEnvironmentFile), and the key atconfig/hub-signing-key.pembefore first boot (mode 600, owned by the service user) — the auto-generators only fire when the file is absent, and a pre-existing valid file is always kept. -
systemctl start phlix-hub, thencurl -fs http://127.0.0.1:8800/health.
If the JWT secret or the signing key cannot be recovered, generate replacements (new 64-char hex secret; delete the key file to auto-regenerate) and accept the consequences above: mass re-login, and re-pairing of servers / re-trusting of federation peers.
Uninstalling
scripts/install.sh --uninstall removes an existing install. By default it is interactive
and prompts separately before each destructive step. The MySQL database and the Let's Encrypt
certificate are kept unless you opt in explicitly:
sudo bash /opt/phlix-hub/scripts/install.sh --uninstall
Add --purge to also drop the database (and user) and delete the Let's Encrypt certificate
via certbot delete. Combine with -y for a fully unattended teardown:
sudo bash /opt/phlix-hub/scripts/install.sh --uninstall --purge -y
Piped, non-interactive runs require an explicit -y to proceed.
What it removes, only if it finds them:
- The
phlix-hubsystemd service —stop,disable, remove the unit,daemon-reload. - HAProxy fragment at
/etc/haproxy/phlix-managed/phlix-hub.cfg.fragment, and/etc/haproxy/haproxy.cfgis rebuilt. If phlix-server is still installed, its frontend and backend stay. If phlix-hub was the last Phlix project, the pre-Phlix snapshot at/etc/haproxy/haproxy.cfg.pre-phlix.bakis restored (orhaproxy.cfgis removed and haproxy is stopped + disabled if no snapshot exists). - The combined PEM at
/etc/haproxy/certs/<domain>.pem. /etc/cron.d/phlix-hub-certbotand/etc/letsencrypt/renewal-hooks/deploy/phlix-haproxy.sh.- The Let's Encrypt cert via
certbot delete --cert-name <domain>— only with--purgeor interactive confirmation. - The MySQL database and dedicated user — only with
--purgeor interactive confirmation. - The install directory (
rm -rf, with a denylist of system paths like/,/etc,/opt). /etc/phlix-hub.env(env file).- The dedicated system user (
phlix-hubor whateverUser=the systemd unit was using) viauserdel— only with--purgeor interactive confirmation. Refuses to touch shared OS accounts (www-data,root,daemon, etc.). Cross-detects phlix-server's systemd unit and refuses to remove a user that's still being used by the sibling service.
System packages (php-*, mysql-server, haproxy, certbot) and ufw rules are left in
place — uninstall them yourself with apt remove / ufw delete if you no longer need them.
Running alongside phlix-server
Both installers can share a single HAProxy instance — they auto-merge into one
/etc/haproxy/haproxy.cfg. Just run both installers normally; whichever runs second detects
the first's fragment and rebuilds a combined config that routes by Host: header.
# 1. Install phlix-hub first (with TLS). curl -fsSL https://raw.githubusercontent.com/detain/phlix-hub/master/scripts/install.sh \ | sudo bash -s -- --domain hub.example.com --admin-email you@example.com -y # 2. Install phlix-server, also with TLS, on a different hostname. curl -fsSL https://raw.githubusercontent.com/detain/phlix-server/master/scripts/install.sh \ | sudo bash -s -- --domain phlix.example.com --admin-email you@example.com -y
After both finish, /etc/haproxy/haproxy.cfg is a Phlix-managed config carrying both
projects' frontends and backends, with HAProxy picking the right cert per SNI hostname from
/etc/haproxy/certs/.
How the merge works. Each install drops a fragment at
/etc/haproxy/phlix-managed/<project>.cfg.fragment with fe_http, fe_https, and backends
sections. A shared rebuilder then assembles the final haproxy.cfg from every fragment it
finds. HAProxy's crt /etc/haproxy/certs/ directive auto-loads every .pem in that directory
and picks the right one per SNI hostname.
The first install snapshots any pre-Phlix haproxy.cfg to
/etc/haproxy/haproxy.cfg.pre-phlix.bak.
Uninstall behaviour: --uninstall removes only that project's fragment and rebuilds. If
other Phlix projects remain, their frontend stays untouched. When the last Phlix project
is uninstalled, the rebuilder restores the pre-Phlix snapshot (or removes haproxy.cfg
outright if there was no pre-Phlix config) and stops/disables haproxy.
The hub server-tunnel port (:8802) is a separate listener — servers connect to that port
directly. Open it on the firewall but don't put it behind the HAProxy 80/443 frontend.
What the managed install makes reachable:
| Port | Surface | Managed HAProxy path | --no-proxy path |
|---|---|---|---|
8800 |
HTTP API + SPA + /health |
routed at / via 80/443 |
ufw-opened (plaintext REST + portal — bearer credentials ride it in the clear; front it with your own TLS or keep it loopback) |
8802 |
server tunnels | direct — ufw-opened (pairing law: never proxied; note it is plaintext, and HELLO carries each server's durable enrollment JWT — per surface, the most secret to lose) |
ufw-opened (same credential note) |
8803 |
client mount (declared unavailable) | routed at /client/ |
loopback only — point your proxy at it |
8804 |
SyncPlay watch-party clients | routed at /syncplay/ |
ufw-opened (plaintext WS — close it once your proxy routes it) |
8805 |
federation MASTER ingress (peer dials) | routed at /relay/federation/ |
ufw-opened (same plaintext caveat) |
2206 |
cross-process channel broker | loopback only | loopback only |
Under the managed HAProxy the two relay WebSocket surfaces are not opened on the firewall. Who dials what, from where:
- Federation (
:8805) — peer-side ingress rides the shared 443 frontend; a leaf's dial target is operator-configured, and the managed/relay/federation/route carrieswss://<master-domain>/relay/federation/{hub_id}with no extra port, TLS included. - SyncPlay watch parties (
:8804) — every browser path consults the operator seam before dialing: both the pinned@phlix/uibundle and the hub's own portal bundle (web-ui/'sbuildRelayUrl— the bundle-wiring rider, shipped at this repo's pin) read the shell'swindow.__PHLIX__.wsUrl/wsPathbootstrap. On a domain-bound managed install the installer itself seeds that seam (R-HUB-13): the generated env block gainsHUB_WS_URL—wss://<domain>under TLS,ws://<domain>on the plain-HTTP managed frontend, always matching the frontend the fragments actually built (certbot-failure fallback included) — andHUB_WS_PATH=/syncplay(the ui seam —HUB_WS_URLis the master switch; a loneHUB_WS_PATHrefines nothing), andSharedUiControllergrammar-validates both (invalid values fail soft to unset). Browsers — portal bundle included — then dial the managed/syncplay/route with no operator step, so8804never needs opening. It is a plaintext listener: an HTTPS-served page'swss://direct dial cannot complete against it (the managed route is the only viable door there), and on a TLS-served hub leaving it closed is pure upside — an open8804buys nothing while publishing a pre-auth token-carrying socket to the world.
The direct form survives exactly where the installer cannot name an origin
clients will actually reach — no-domain managed installs and --no-proxy
boxes. There the seam stays unset and clients fall back to the legacy
ws(s)://<page-host>:8804/syncplay/{server_id} with the port fixed by
SyncPlayRelayWorker::DEFAULT_PORT, so --no-proxy installs open
8804/8805 in ufw (when it is active) and the plain
ws://<host>:8804 / ws://<host>:8805 forms documented in
docs/websockets.md are reachable there directly.
The manual escape hatch stays open for everyone else too: hand-set
HUB_WS_URL=wss://<hub-domain> with HUB_WS_PATH=/syncplay in
/etc/phlix-hub.env (both grammar-validated per the env table below; the
installer never overwrites a non-empty value) and restart phlix-hub.
--no-proxy installs open 8804/8805 in ufw (when it is active), so the plain
ws://<host>:8804 / ws://<host>:8805 forms documented in
docs/websockets.md are reachable there directly.
If you'd rather use your own reverse proxy (nginx, Caddy, Traefik, etc.) instead of the
managed HAProxy, pass --no-proxy to either install script. Each service then listens on its
own port (8800 / 8802 / 8803 / 8804 / 8805 for phlix-hub, 8096 for phlix-server) and you point
your proxy at those — replicate the same path prefixes the managed config uses (/client/,
/syncplay/, /relay/federation/) and see §8 for the WebSocket pass-through requirements.
Everything else is already namespaced (env files, systemd units, install dirs, service users, MySQL DBs, backend ports, certbot artefacts) so there are no other co-install conflicts.
Quick start (development)
git clone https://github.com/detain/phlix-hub.git cd phlix-hub composer install # Point the Hub at a MySQL instance (see Configuration reference below). export HUB_DB_HOST=127.0.0.1 HUB_DB_USER=phlix_hub HUB_DB_PASSWORD=phlix_hub HUB_DB_NAME=phlix_hub export HUB_JWT_SECRET="$(openssl rand -hex 32)" php scripts/run-migrations.php # create the tables (idempotent) php start.php start # start the Hub (Ctrl-C to stop) curl http://localhost:8800/health # => {"status":"ok",...}
Then open http://localhost:8800/app/signup to create the first account — it is auto-promoted to
admin. On a public deployment make sure that first signup is yours before opening :8800
to the network, then lock registration down: HUB_SIGNUPS_ENABLED=false at boot, or flip the
live auth.signups_disabled hub setting under /app/admin/settings (no restart needed).
After signing in, /app/servers lists your servers and the claim flow there (legacy
/claim-server 302s to it) walks through pairing a new one.
Run
php start.php start -dto daemonize;stop,restart,reload, andstatusare also available.
CLI (bin/phlix)
A small webman/console CLI
ships at bin/phlix:
php bin/phlix list # list available commands (works with no database) php bin/phlix migrate # apply migrations/*.sql (idempotent; tracking table) php bin/phlix smoke:jwt # smoke-test the JWT create/validate round-trip php bin/phlix oauth:client:register # register an OAuth client (prints the one-time secret) php bin/phlix oauth:client:list # list registered clients and their status php bin/phlix oauth:client:disable # disable a client (add --revoke-tokens to cut live tokens) php bin/phlix user:list # list hub accounts php bin/phlix user:create <username> --email <addr> [--password …] [--admin] [--display-name …] php bin/phlix user:promote <user-or-email> # grant admin; --revoke demotes php bin/phlix user:delete <user-or-email> [--force] # last-admin is protected even with --force
user:* is the only recovery path when the last admin locks itself out —
signup can be closed (auth.signups_disabled) and the portal's admin controls
require an admin, so an account that forgets its way out of the building can
only be let back in from a root shell on the hub host. --password may be
omitted (interactive prompt) or piped as a single stdin line for scripting;
every command accepts --json for machine-readable output.
migrate is the CLI equivalent of php scripts/run-migrations.php.
Production install on Ubuntu
These steps target Ubuntu 24.04. On 22.04 the distro PHP is 8.1, which predates the Hub's 8.3 floor — the guidance below is to move to 24.04 (or newer) rather than pull in a third-party PHP build. Run as a sudo-capable user.
1. System packages
# PHP: use the version-agnostic php-* package names so apt installs the # distro's current PHP. Ubuntu 24.04 ships PHP 8.3 by default, which meets # the Hub's requirement. sudo apt update sudo apt install -y \ php-cli php-mysql php-mbstring php-curl \ php-xml php-bcmath php-gd php-zip \ git unzip mysql-server php -v # confirm PHP 8.3 or newer # Composer curl -sS https://getcomposer.org/installer | php sudo mv composer.phar /usr/local/bin/composer
pcntl,posix, andsodiumship with thephp-clipackage on Ubuntu — verify withphp -m | grep -E 'pcntl|posix|sodium'. If your distro's default PHP is older than 8.3, upgrade to Ubuntu 24.04 (or newer) rather than pulling in a third-party PHP build.
2. MySQL: database, user, and grants
Secure the server first (sets the root password, removes anonymous users, etc.):
sudo mysql_secure_installation
Then create the database and a dedicated, least-privilege user. Open a root shell with
sudo mysql and run:
-- Database (utf8mb4 throughout) CREATE DATABASE phlix_hub CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- Dedicated user. The Hub connects over TCP to 127.0.0.1 by default, so the -- user host must match. Use a strong, unique password. CREATE USER 'phlix_hub'@'127.0.0.1' IDENTIFIED BY 'CHANGE-ME-strong-password'; -- Least privilege: only the rights the app needs on its own schema. GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, REFERENCES ON phlix_hub.* TO 'phlix_hub'@'127.0.0.1'; FLUSH PRIVILEGES;
Notes:
- The migration runner issues
CREATE TABLE/ALTER TABLE, soCREATE,ALTER, andINDEXare required in addition to the CRUD grants. - If the Hub runs on a different host than MySQL, create the user for that host (or
'%') and setHUB_DB_HOSTaccordingly. Make sure MySQL'sbind-addressallows remote connections. - MySQL distinguishes
'localhost'(unix socket) from'127.0.0.1'(TCP). The Hub uses TCP, so grant to'127.0.0.1'. To also allow socket logins for manualmysqluse, create a second'phlix_hub'@'localhost'user.
Verify the credentials work:
mysql -h 127.0.0.1 -u phlix_hub -p phlix_hub -e 'SELECT 1;'
3. Application code
sudo git clone https://github.com/detain/phlix-hub.git /opt/phlix-hub cd /opt/phlix-hub sudo composer install --no-dev --optimize-autoloader # Create the dedicated service account first (same shape install.sh uses), # then hand the whole tree to it. The unit below runs as phlix-hub, so the # tree — including .logs/, var/ and config/ (the Ed25519 signing key lands in # config/ on first boot) — must be writable by phlix-hub, NOT by www-data: # a world-shared web user would defeat the isolation law above. sudo getent group phlix-hub >/dev/null || sudo groupadd --system phlix-hub sudo getent passwd phlix-hub >/dev/null \ || sudo useradd --system --no-create-home --shell /usr/sbin/nologin --gid phlix-hub phlix-hub sudo mkdir -p .logs sudo chown -R phlix-hub:phlix-hub /opt/phlix-hub
4. Environment configuration
The Hub is configured almost entirely through environment variables (see the
reference) — with five exceptions that live in the
merged config files only and have no env seam: hub_signing_key_path,
dns_zone_dir, tls_certs_dir, acme_email and dns_provider, read with
built-in defaults by HubServicesProvider and gating the DNS-alias/TLS
surfaces. Create an env file the service will load:
sudo tee /etc/phlix-hub.env >/dev/null <<EOF HUB_HOST=0.0.0.0 HUB_PORT=8800 HUB_WORKERS=4 HUB_PUBLIC_DOMAIN=hub.example.com HUB_DB_HOST=127.0.0.1 HUB_DB_PORT=3306 HUB_DB_USER=phlix_hub HUB_DB_PASSWORD=CHANGE-ME-strong-password HUB_DB_NAME=phlix_hub # REQUIRED in production: a real >=32-byte secret, generated as the file is # written (the heredoc is deliberately UNQUOTED so $(...) expands once). # Boot-time pre-flight refuses documented placeholder literals outright. HUB_JWT_SECRET=$(openssl rand -hex 32) EOF sudo chmod 600 /etc/phlix-hub.env
The
HUB_DB_PASSWORDplaceholder above still needs a real value matching the MySQL user you created in step 2 — the boot gate polices onlyHUB_JWT_SECRET.
HUB_JWT_SECRET is enforced before the worker fork (R14-08): a value on the placeholder denylist
(CHANGE-ME-run-openssl-rand-hex-32, change-me, your-secret-here, replace-me, … —
case/space-insensitive) aborts startup with exit 1 rather than minting tokens under a
publicly-guessable key. An unset (or <32-byte) secret falls back to a random per-process key
only in development (APP_ENV/HUB_ENV = dev|local, or HUB_JWT_ALLOW_DEV_SECRET=1) — that
fallback invalidates every token on restart and differs per worker, so set a real secret in
production with openssl rand -hex 32 and keep it stable.
5. Firewall the exposure you just accepted
A fresh manual install is internet-reachable on five ports. Set this reality straight before step 7 starts the daemon:
:8800serves the admin console and the REST API. Behind nginx + TLS you may keep it loopback-only; exposed directly it is the whole control plane.:8802,:8803,:8804,:8805(relay, client-relay, SyncPlay, federation) always bind all interfaces — the worker code hard-codes the bind address (websocket://0.0.0.0:<port>inRelayWorker.php:253,ClientRelayWorker.php:195,SyncPlayRelayWorker.php:444,FederationWorker.php:308).HUB_HOSTrestricts only:8800. SettingHUB_HOST=127.0.0.1behind nginx still leaves every relay listener on the public interface unless you firewall it.- The installer-managed path opens
ufwrules per-port only when ufw is active (see the ports table under Install modes); the manual path runs no firewall at all. Ifsudo ufw statussaysinactive, you have no host filtering whatsoever — either enable it with an explicit policy, or open the ports on your cloud security group. Decide per port:8804only needs to be open while plaintext watch-party clients dial it directly (once your TLS proxy routes/syncplay/, close it);8805only needs inbound dials if this hub acts as a federation master.
Example for an nginx-fronted hub that only serves its own media servers over the direct tunnels:
sudo ufw default deny incoming && sudo ufw allow 22/tcp \ && sudo ufw allow 80/tcp && sudo ufw allow 443/tcp \ && sudo ufw allow 8802/tcp && sudo ufw enable
6. Run migrations
Run the migrator as root — the env file is mode 600 root-owned, so an
unprivileged re-execution of the grep | xargs form would silently expand to an
empty environment and the runner would fall back to config/database.php
defaults instead of your step-2 password (the installer itself runs as root end
to end, so its own migrator call at scripts/install.sh:1836 inherits the real
environment):
sudo bash -c 'set -a; . /etc/phlix-hub.env; set +a; php /opt/phlix-hub/scripts/run-migrations.php'
The runner records applied migrations in a migrations table and is idempotent — re-running
it after a successful apply is a no-op.
Editing an already-applied migration. The
migrationsledger also stores a comment-normalized checksum of each applied file (achecksumcolumn added by migration041_migrations_checksum). If you hand-edit the SQL of a migration that has already run (a "rewrite-class" migration), the nextrun-migrations.php/bin/phlix migratedetects the changed content, logs a WARNING (viaerror_log(), visible in the deploy output / CLI stderr), safely re-applies the file, and refreshes its stored checksum — instead of silently skipping it forever on filename alone. Header/comment-only edits do not trigger a re-apply (comments are stripped before hashing). Migrations that already existed before the checksum column was introduced are backfilled without re-running, so upgrading never mass-re-applies your existing schema. Because a re-applied file executes again, keep migrations re-run-safe (CREATE TABLE IF NOT EXISTS, additive/idempotent DDL) — the same contract that already governs writing migrations.
7. Run as a systemd service
scripts/install.sh is the canonical generator of the unit file — it writes the
full hardened definition below (service user, port gate, graceful reload, resource
limits, sandbox). If you install by hand, mirror it; the inline copy here is kept
current with the template in scripts/install.sh (step 7, "systemd service").
sudo tee /etc/systemd/system/phlix-hub.service >/dev/null <<'EOF' [Unit] Description=Phlix Hub Documentation=https://docs.phlix.media After=network.target mysql.service Wants=mysql.service StartLimitIntervalSec=500 # Burst of 10 (not 5): with the ExecStartPre port gate below each failed start # attempt takes up to ~93 s, so 5 failures would fit inside a 500 s window and # trip start-limit-hit — leaving the hub down until a manual reset-failed. StartLimitBurst=10 [Service] Type=simple User=phlix-hub Group=phlix-hub WorkingDirectory=/opt/phlix-hub EnvironmentFile=/etc/phlix-hub.env # Swoole's RemoteObject bridge writes a lock to $HOME/.swoole; the system user # has no home, so point HOME at a writable path inside the install root. Environment="HOME=/opt/phlix-hub/var" # Cross-origin desktop access (optional; M-HUB-09): the Windows client's # hub-mode renderer runs the same UI bundle at origin app://- against this # hub's https base, so its REST calls are cross-origin for the browser # engine. Unset, the hub denies all CORS (the shipped default); uncomment to # grant the desktop origin. The position law applies — this opens public # control endpoints to preflight, not authed REST (authed routes answer the # auth challenge first, before any CORS grant). See 'Security & posture # envs' below and openapi.yaml 'Cross-origin (CORS)'. #Environment="PHLIX_CORS_ALLOW_ORIGINS=app://-" # Restart-race gate (2026-07-10 incident): the OLD master can hold its listen # sockets for seconds-to-minutes after SIGTERM; without this wait the new # master throws "Address already in use" and crash-loops. Waits up to 90 s for # :8802/:8803/:8804/:8805/:2206 (the config/server.php defaults) to be free. # If you move HUB_RELAY_PORT, HUB_CLIENT_RELAY_PORT, HUB_SYNCPLAY_PORT, # HUB_FEDERATION_PORT or HUB_CHANNEL_PORT, update the gate's values to match. ExecStartPre=/usr/bin/env bash /opt/phlix-hub/scripts/wait-for-free-ports.sh -t 90 8800 8802 8803 8804 8805 2206 ExecStart=/usr/bin/php /opt/phlix-hub/start.php start # GRACEFUL reload: Workerman arms its SIGKILL timer only for SIGUSR1, so # SIGUSR2 (Worker.php:1390/$gracefulStop) is the reload that does NOT cut live # relay tunnels and in-flight HLS. POST /api/v1/admin/restart sends the same # signal, so 'systemctl reload' and the in-app Restart button behave alike. # (The pid file lives at /opt/phlix-hub/var/hub.pid — do NOT point # HUB_PID_FILE at /var/run: the sandbox below cannot write there.) ExecReload=/bin/kill -SIGUSR2 $MAINPID ExecStop=/bin/kill -SIGTERM $MAINPID Restart=on-failure RestartSec=3s TimeoutStopSec=30 # Must cover the up-to-90 s ExecStartPre port gate plus worker boot. TimeoutStartSec=120 # Swoole 6's io_uring loop pins locked memory; the 8 MB RLIMIT_MEMLOCK default # makes io_uring init fail with ENOMEM and workers intermittently drop conns. LimitMEMLOCK=infinity # systemd does NOT read /etc/security/limits.conf: this line is what the daemon # gets. The relay surfaces run on count=1 workers — ONE process holds a socket # per paired server AND per connected client — so the 1024 soft default stops # accepting tunnels with "Too many open files" long before anything else binds. LimitNOFILE=1048576 StandardOutput=journal StandardError=journal SyslogIdentifier=phlix-hub # Sandbox hardening. The install root stays READ-ONLY; every runtime write is # pinned under ReadWritePaths: .logs/ (app/error/hub/relay/audit + workerman # logs), var/ (Swoole HOME lock, Smarty compile/cache, the DI container compile # dir, config/ needs write for the Ed25519 hub signing key — HubServicesProvider # writes config/hub-signing-key.pem, mode 0600 — Workerman pid/status files). # NOTE: MemoryDenyWriteExecute (breaks PHP JIT/opcache), PrivateDevices and # SystemCallFilter (io_uring is syscall-sensitive) are deliberately NOT set. # The optional DNS-alias feature (StaticZoneManager) defaults its zone dir # under /home/phlix/data — ProtectHome hides it; enablement must repoint # 'dns_zone_dir' into a path listed here. NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/opt/phlix-hub/.logs /opt/phlix-hub/var /opt/phlix-hub/config RestrictNamespaces=true LockPersonality=true RemoveIPC=true ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true ProtectHostname=true ProtectClock=true RestrictSUIDSGID=true RestrictRealtime=true [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload sudo systemctl enable --now phlix-hub sudo systemctl status phlix-hub curl http://localhost:8800/health
8. Reverse proxy & TLS
Terminate TLS at a reverse proxy in front of the Hub. The HTTP API (8800) plus the three
path-namespaced WebSocket surfaces need to be reachable through it: the client mount
(8803, GET /client/{server_id}), the SyncPlay relay (8804, GET /syncplay/{server_id})
and — only when this hub advertises itself to other hubs as a federation master — the
federation ingress (8805, GET /relay/federation/{hub_id}). (The 8803 listener still
serves the rate-limited, authenticated 1011 refusal — mount traffic itself is declared
unavailable in this topology; see the worker table above.) Example nginx server block:
server { listen 443 ssl; server_name hub.example.com; ssl_certificate /etc/letsencrypt/live/hub.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/hub.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:8800; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Belt-and-braces upgrade settings: :8800 itself serves no WebSocket # surface (the :8802 server tunnels are direct-connect per the pairing # law, and every proxied mount has its own leg below). proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; } # Client mount (GET /client/{server_id}, WebSocket) — the leg the reachability # promise above makes, matching the managed HAProxy be_hub_client_relay route # (scripts/install.sh). X-Forwarded-For / X-Real-IP are what the mount reads # to key its per-IP rate limit behind a proxy. Honest posture: this mount is # DECLARED UNAVAILABLE in the deployed topology — after the auth ladder # (limiter -> relay token -> ownership) :8803 answers every upgrade with WS # close 1011 and logs client_mount_bridge_unavailable (docs/websockets.md). # Route it anyway so clients get that loud structured refusal, not a # proxy-level error. Live client-to-server traffic rides the HTTP relay # proxy (/api/v1/servers/{id}/proxy/*), already covered by `location /`. location /client/ { proxy_pass http://127.0.0.1:8803; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 3600s; } # SyncPlay relay (watch-party clients). The path must reach the listener # unprefixed — its parser anchors on ^/syncplay/. Auth rides the # `Authorization: Bearer <token>` header or the `bearer, <token>` WebSocket # subprotocol, never the query string. location /syncplay/ { proxy_pass http://127.0.0.1:8804; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 3600s; # long-lived rooms; the managed HAProxy carries timeout tunnel 1h } # Federation master ingress (leaf-hub dials). Authentication is the Ed25519 # handshake ceremony, not TLS — but wss:// stays the production shape. location /relay/federation/ { proxy_pass http://127.0.0.1:8805; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; } }
If you allocate per-server subdomains, point a wildcard record (*.hub.example.com) at the
Hub and add a matching wildcard TLS certificate.
TLS for server subdomains: automated ACME (Let's Encrypt) provisioning is not built in. Provision certificates out-of-band (e.g. a wildcard cert via certbot DNS-01) and point the Hub at them.
Docker
A Dockerfile is provided. The image builds on the shared base
(ghcr.io/detain/phlix-base: PHP 8.3 + Swoole + php-uv) and runs the Workerman daemon
(start.php) as PID 1 — no nginx, no php-fpm, no supervisord. The entrypoint
(docker/docker-entrypoint.sh) applies pending migrations at boot, then execs the daemon;
the image EXPOSEs 8800 8802 8803 8804 8805 and its healthcheck probes /health.
docker build -t phlix-hub . # Export real values first — the shell below refuses to start the container with them unset, # and placeholder JWT literals are refused at boot anyway. export HUB_DB_PASSWORD='…matching a real MySQL user…' docker run -d --name phlix-hub \ -p 8800:8800 -p 8802:8802 -p 8803:8803 -p 8804:8804 -p 8805:8805 \ -e HUB_DB_HOST=host.docker.internal \ -e HUB_DB_USER=phlix_hub \ -e HUB_DB_PASSWORD="${HUB_DB_PASSWORD:?export HUB_DB_PASSWORD before running}" \ -e HUB_DB_NAME=phlix_hub \ -v phlix-hub-config:/var/phlix/config \ phlix-hub
HUB_JWT_SECRET may be passed explicitly (-e HUB_JWT_SECRET="$(openssl rand -hex 32)") or
left unset: the entrypoint then generates one key once and persists it to
/var/phlix/config/hub_jwt_secret — mount that path as a volume (as above) so sessions survive
container re-creation. An ephemeral generated key without the volume invalidates all tokens on
every restart.
⚠ The JWT volume is not the whole story — the Ed25519 identity key is state #2 and it lives
nowhere persistent in the default layout. The hub auto-generates its hub signing key at
/var/www/html/config/hub-signing-key.pem on first boot, and /var/www/html is image-internal:
every container re-creation (every upgrade, every docker rm+run) silently generates a fresh
identity, so all paired servers fail verification and all federation peers refuse the hub —
the exact incident path the "three pieces of state" table above warns about, arriving in Docker
with no warning line. Preserve it with a single-file bind-mount, readable by the daemon user
(nobody, uid 65534 — the image runs USER nobody):
# once, from your persisted secret store; the key must exist before first boot to avoid a throwaway generation docker run -d --name phlix-hub \ … \ -v /var/phlix/secrets/hub-signing-key.pem:/var/www/html/config/hub-signing-key.pem:ro \ phlix-hub # host side: chown 65534:65534 /var/phlix/secrets/hub-signing-key.pem && chmod 600 …
(Or point hub_signing_key_path — the config/server.php array key described above — at a path
under your mounted volume; that file is baked into the image, so the bind-mount form above is the
no-source-edit route.)
⚠ The quick run above publishes all five surfaces in the clear. With the TLS envs at their
documented defaults, -p 8802:8802 exposes a plaintext listener that carries the enrollment
JWT in its HELLO frame, :8804/:8805 are pre-auth token-bearing sockets, and :8800 is
plain HTTP — and Docker publishes them on every host interface. Two honest postures:
- Proxy-fronted (recommended): bind publishes to loopback —
-p 127.0.0.1:8800:8800,-p 127.0.0.1:8803:8803, … — and let the host's reverse proxy be the only thing that reaches the hub, TLS included (see 8. Reverse proxy & TLS). Do not publish:8804at all on a TLS-served hub: browsers cannot dial it from an HTTPS page anyway. - Direct-dialed: if media servers must reach the container without a TLS-terminating proxy,
set
HUB_RELAY_TLS=1withHUB_RELAY_TLS_CERT/HUB_RELAY_TLS_KEYbefore pairing anything, andHUB_RELAY_REQUIRE_TLS=1so a misconfigured plaintext:8802refuses to boot instead of leaking enrollment credentials. Federation peers want the same treatment on:8805(HUB_FEDERATION_TLS*/HUB_FEDERATION_REQUIRE_TLS). The family is tabulated in the configuration reference.
Migrations run automatically from the entrypoint on every boot (look for the
PHLIX-HUB-MIGRATIONS-OK banner); set HUB_MIGRATIONS_STRICT=1 to refuse booting on a
half-migrated schema instead of the start-anyway default. A manual idempotent re-run stays
available:
docker exec phlix-hub php /var/www/html/scripts/run-migrations.php
Point HUB_DB_HOST at a reachable MySQL instance (a linked container, host.docker.internal, or
an external host).
Logs. The application writes rotating files under .logs/ inside the container —
app.log, error.log, hub.log, relay.log, audit.log (5 channels, 30 rotations each;
see config/logger.php) — not stdout. docker logs phlix-hub
therefore shows only the entrypoint banners and Workerman's own console output
(Workerman's internal log file is the sixth log path in that family, HUB_WORKERMAN_LOG,
default .logs/workerman.log). To follow the application:
docker exec phlix-hub tail -f /var/www/html/.logs/app.log
.logs/ lives in the image's workdir (/var/www/html), so it is replaced on every
container re-creation — mount a volume (-v phlix-hub-logs:/var/www/html/.logs) to
retain logs across upgrades.
Configuration reference
All settings are environment variables, read in config/. Defaults shown are the
development fallbacks.
Server (config/server.php)
| Variable | Default | Description |
|---|---|---|
HUB_HOST |
0.0.0.0 |
HTTP bind address |
HUB_PORT |
8800 |
HTTP listen port |
HUB_WORKERS |
2 |
Number of HTTP worker processes |
HUB_WORKERMAN_LOG |
.logs/workerman.log |
Workerman's own log file |
HUB_DB_WAIT_SECONDS |
60 |
Docker entrypoint: seconds to wait for the MySQL listener (TCP probe of HUB_DB_HOST:HUB_DB_PORT) before running migrations, so a container that wins the boot race against its database no longer burns its one migration attempt and ships an un-migrated schema behind a green DB-free /health. A non-resolving host aborts the wait immediately (typo ≠ slow boot); 0 = old single-attempt behavior |
HUB_LOG_LEVEL |
info |
Verbosity of the primary file channel (config/logger.php); one of the eight Monolog names, invalid → boot fails loud. The old hardcoded debug grew one huge file per day (rotation caps by day count, not bytes) |
HUB_LOG_DIR |
<install>/.logs |
Directory for all five rotating logs. The shipped systemd unit grants ReadWritePaths only to the install's .logs, so relocate carefully |
HUB_PUBLIC_DOMAIN |
phlix.media |
Base domain for per-server subdomains |
HUB_BASE_URL |
https://<HUB_PUBLIC_DOMAIN>, else http://localhost:<HUB_PORT> |
Public base URL baked into enrollment JWTs and the JWKS URL — must be reachable by enrolled servers, not just in-process |
HUB_RELAY_PORT |
8802 |
Server-tunnel WS listen port (RelayWorker) |
HUB_CLIENT_RELAY_PORT |
8803 |
Client-mount WS listen port (ClientRelayWorker) |
HUB_SYNCPLAY_PORT |
8804 |
SyncPlay relay listen port (SyncPlayRelayWorker) |
HUB_FEDERATION_PORT |
8805 |
Hub-to-hub federation listen port (FederationWorker) |
HUB_CHANNEL_PORT |
2206 |
Cross-process channel broker (relay RPC between HTTP workers and the count=1 relay) |
HUB_MAX_SERVERS_PER_USER |
0 |
Per-user server-claim quota (0 = unlimited); live twin of the server.max_servers_per_user hub setting |
HUB_MAX_USERS_PER_SERVER |
0 |
Per-server collaborator cap (0 = unlimited); live twin of server.max_users_per_server |
HUB_ENROLLMENT_TTL |
604800 |
Enrollment-JWT lifetime (seconds); effective value re-resolved per mint (hub-setting override wins) |
HUB_AUDIT_RETENTION_DAYS |
365 |
Days of audit_logs the maintenance reaper retains (R19-11). Deliberately env-only, not a hub_settings key — an operator dial, not an admin-UI one. Must be ≥ 1: non-positive or oversized values are clamped back to 365 with a logged warning — a 0 here can never arm a wipe-everything sweep |
HUB_MCP_PLAYBACK_CONTROL |
false |
Registers the playback_control MCP tool — the only MCP tool that mutates server state. Read the S261 review warning in config/server.php before enabling (retroactively activates the scope on pre-existing tokens) |
HUB_MCP_SSE_KEEPALIVE / HUB_MCP_SSE_MAX_SECONDS |
15 / 900 |
GET /mcp SSE keep-alive interval and hard stream-lifetime ceiling |
HUB_RELAY_RECONNECT_DRAIN_GRACE |
5.0 |
Seconds an incumbent tunnel keeps draining in-flight requests after a validated server reconnect displaces it (0 = hard displacement) |
The six listen-port envs were wired through
config/server.phpin R14-15 — before that the auxiliary ports existed only as?? <default>fallbacks no config set (configurability theater). Defaults are byte-identical to the previous literals, so behaviour with the envs unset is unchanged.
Security & posture envs
Verified against config/ and the consuming classes; each default preserves shipped behaviour.
Where a live hub_settings twin exists (admin-console override), it is named. Exactly two
twin'd keys carry restart: true in the vendored schema (@phlix/shared hub-settings.schema.json):
server.metrics.enabled and server.rate_limit — for those, the admin-console value is read at
boot per worker, so changing it needs a restart to take effect (all other twins are live-per-request).
The same law cuts the other way for rate limiting: server.rate_limit (a JSON override) merges over
the PHLIX_HUB_RATELIMIT_* envs below, so when it is set the env defaults are NOT the whole truth.
| Variable | Default | What it does / unblocks |
|---|---|---|
HUB_SIGNUPS_ENABLED |
true |
false/0/no/off closes self-service registration: POST /api/v1/auth/{register,signup} answers 403 auth.signups_disabled before any hashing work. Live twin: auth.signups_disabled |
HUB_FEDERATION_ENABLED |
true |
false stops new federation dials, drops inbound DATA frames pre-verification, and rejects controller mutators with 409 provider.not_configured. The :8805 listener stays bound until restart (pre-fork socket). Live twin: federation.enabled |
HUB_MAINTENANCE_MODE |
false |
true answers 503 provider_unavailable for API traffic. Exempt by design: /health, /api/v1/auth/*, hub-settings + admin-settings GET/PUT (the off-switch), /api/v1/admin/restart; the SPA shell and hashed assets still serve. Live twin: hub.maintenance_mode |
HUB_RELAY_TLS |
false |
Terminates TLS on the :8802 server-tunnel listener (wss://) — what you must enable to pair servers over untrusted networks. Needs HUB_RELAY_TLS_CERT / HUB_RELAY_TLS_KEY (both default null) |
HUB_RELAY_TLS_CERT / HUB_RELAY_TLS_KEY |
null |
PEM paths for the :8802 TLS context above |
HUB_RELAY_REQUIRE_TLS |
false |
Fail-closed :8802 bind mandate — the listen-side mirror of the server's PHLIX_RELAY_REQUIRE_TLS (R17-01): true makes the relay worker REFUSE to boot when TLS is off, instead of binding a cleartext listener that carries enrollment JWTs. false = warn-only — a plaintext bind logs one loud SECURITY advisory on the relay channel at boot |
HUB_FEDERATION_TLS |
false |
Terminates TLS natively on the :8805 hub-to-hub federation listener (wss://) — for masters reached WITHOUT a TLS-terminating upstream proxy. Needs HUB_FEDERATION_TLS_CERT / HUB_FEDERATION_TLS_KEY (both default null). A plaintext bind logs one loud SECURITY advisory on the relay channel at boot (M22) |
HUB_FEDERATION_TLS_CERT / HUB_FEDERATION_TLS_KEY |
null |
PEM paths for the :8805 TLS context above |
HUB_FEDERATION_REQUIRE_TLS |
false |
Fail-closed :8805 bind mandate, the federation sibling of HUB_RELAY_REQUIRE_TLS above: true makes FederationWorker::start() throw before constructing the listener when TLS is off — no cleartext federation socket is ever bound (federation inbound stays down, the hub serves the rest). false = warn-only. Keep unset on managed installs, where HAProxy fronts :8805 and terminates TLS upstream |
HUB_COOKIE_INSECURE |
unset | Drops Secure from cookies set through Response::cookie(). The bearer-only hub never sets session cookies (that surface was deleted as dead code); this escape exists only for local plain-HTTP experiments with a future cookie |
XDEBUG_MODE |
off |
Xdebug mode inside the container (docker/php.ini:30, installer-generated ini on bare-metal provision — scripts/install.sh:410). Never set debug or trace in production: start_with_request = trigger means any request carrying the trigger value arms step-debugging/tracing, and traces land world-writable in /tmp/xdebug (docker-entrypoint.sh:48) |
XDEBUG_TRIGGER |
devdebug |
Trigger value for the mode above (docker/php.ini:32). Treat it as a secret if you ever enable a debug/trace mode on a reachable host; the former .env.example carried it as plain text and was deleted because nothing in this repo loads .env files (no dotenv in the lock) |
PHLIX_CORS_ALLOW_ORIGINS |
unset — deny-all | Cross-origin allowlist for the hub's REST HTTP surface (src/Http/CorsPolicy.php, read once at router construction — boot-scoped, changes need a hub restart): comma-separated exact lowercase origins (PHLIX_CORS_ALLOW_ORIGINS=https://hub.example.com,app://-); a malformed entry — wildcard, uppercase, path, whitespace — fails LOUD at boot instead of silently denying at runtime. Allowlisted origins earn the OPTIONS 204 preflight and an Access-Control-Allow-Origin echo on actual replies; Access-Control-Allow-Credentials is never emitted (hub REST is bearer-auth, and a cross-origin cookie ride is exactly what this posture refuses). A disallowed-origin preflight is byte-identical to an unknown-path 404 — a probe cannot map the route table. app://- is the origin the desktop (Electron Windows client) hub-mode renderer presents — add it when the hub serves packaged desktop clients. Honest scope limit (the position law, M-HUB-09 r2/r3): OPTIONS on AUTHED routes runs the auth chain before any CORS grant, so this knob opens cross-origin reach for the public control endpoints (health, auth, server-claims trio); authed desktop hub-mode REST deliberately stays beyond browser-CORS reach there — its live cross-origin work rides the CORS-exempt relay WebSockets (:8802–:8804). The same-origin /app portal needs nothing: same-origin fetches never preflight. Full policy text in openapi.yaml §"Cross-origin (CORS)" |
HUB_WS_HOST |
falls back to HUB_PUBLIC_DOMAIN, then your-hub-host |
Host advertised in the X-WS-Endpoint steer when a plain HTTP GET hits the :8802/:8803 WebSocket mounts — host only: the steer's ws:// scheme and default port are fixed, not proxy/port-aware |
HUB_REQUESTS_AUTO_APPROVE |
false |
Auto-approves freshly filed media requests (best-effort, after the pending row lands). Live twin: requests.auto_approve |
HUB_INVITE_DEFAULT_EXPIRY |
604800 (7 days) |
Default invite lifetime in seconds when the creator omits expires_in; 0 = never expire. An explicit body value always wins. Live twin: invite.default_expiry_seconds |
SPA shell hints (window.__PHLIX__)
The /app shell injects a small bootstrap payload (R12-02). Both hints default to null,
which keeps the SPA's legacy same-origin derivation of its WebSocket endpoints — set them
only behind a reverse proxy or port map that breaks same-origin:
| Variable | Default | Description |
|---|---|---|
HUB_WS_URL |
unset → null; seeded ws(s)://<domain> by install.sh on domain-bound managed installs |
Explicit WS base origin (ws(s)://host[:port], no path). Grammar-validated; an invalid value fails soft to null |
HUB_WS_PATH |
unset → null; seeded /syncplay there |
Single absolute socket path (e.g. /syncplay). Grammar-validated; invalid → null |
Runtime files & container cache
| Variable | Default | What it does |
|---|---|---|
HUB_PID_FILE |
var/hub.pid (under the project root) |
Workerman master PID file. start.php assigns it to Worker::$pidFile; the hardened systemd unit keeps it writable via ReadWritePaths — do NOT point it at /var/run (the sandbox cannot write there and POST /api/v1/admin/restart would 500) |
HUB_STATUS_FILE |
var/hub.status (under the project root) |
Declared in config/server.php ($envStr('HUB_STATUS_FILE', …)) for Workerman's status-snapshot convention; no consumer in src/ today — honest census entry, not a live dial |
HUB_MAINTENANCE_HEARTBEAT_FILE |
var/maintenance-heartbeat.json |
S312 liveness record of the SEPARATE-FORK maintenance worker: written from inside the guarded sweep callback, read by /health — a crash-looping sweeper stops reporting healthy within HUB_MAINTENANCE_STALE_SECONDS. Must stay on a filesystem shared between the forks (not a private tmpfs) |
HUB_MAINTENANCE_STALE_SECONDS |
180 |
Seconds without a COMPLETED maintenance sweep before /health calls the maintenance worker down (default = 3× config/process.php's maintenance.poll_seconds) |
PHLIX_HUB_CONTAINER_COMPILE |
unset (off) | Truthy (1/true/yes/on) enables PHP-DI's compiled-container cache (src/Common/Container/ContainerFactory.php::shouldCompile()), writing to var/cache — a production startup-speed dial; leave off in development so definition edits take effect immediately |
Rate limiting (config/server.php rate_limit)
Each abuse-prone surface has its own rate limiter (a single login-grade limiter is wrong for
everything but login). Sixteen profiles exist in three custody postures: login, mcp, and
signup are shared and DB-backed; eight HTTP surfaces (proxy, heartbeat, jwks, alexa,
server_claims, invite_redeem, client_relay_token, client_request_create) use per-worker in-memory limiters (soft-global — see the caveat
below); the five count=1-worker surfaces (relay_connect, client_mount,
syncplay_mount, federation_connect, federation_frame) are per-worker too,
but per-worker is global there. Every threshold is
env-overridable; absent keys fall back to the defaults below. HTTP surfaces that trip return
429 with a Retry-After header and {"error":"Too Many Requests","code":"rate_limited"}; the
WebSocket handshakes (:8802/:8803/:8804/:8805) reject with WS close code 1013 (Try Again Later) instead.
| Variable | Default | Surface / key |
|---|---|---|
PHLIX_HUB_RATELIMIT_CAP |
10000 |
Max distinct keys tracked per limiter (memory ceiling) |
PHLIX_HUB_RATELIMIT_LOGIN_MAX / _LOGIN_WINDOW |
5 / 900 |
Login attempts, keyed by identity — DB-backed, global |
PHLIX_HUB_RATELIMIT_MCP_MAX / _MCP_WINDOW |
10 / 900 |
MCP personal-access-token auth failures, keyed mcp:auth:* — DB-backed, global |
PHLIX_HUB_RATELIMIT_SIGNUP_MAX / _SIGNUP_WINDOW |
3 / 3600 |
Account signup attempts, keyed auth:signup:<ip> — DB-backed, global, counts successes too |
PHLIX_HUB_RATELIMIT_PROXY_MAX / _PROXY_WINDOW |
600 / 60 |
Relay proxy, keyed proxy:{userId} (hit after the auth gate; generous for HLS bursts) |
PHLIX_HUB_RATELIMIT_HEARTBEAT_MAX / _HEARTBEAT_WINDOW |
30 / 60 |
Server heartbeat, keyed heartbeat:{serverId} (after JWT validation) |
PHLIX_HUB_RATELIMIT_JWKS_MAX / _JWKS_WINDOW |
120 / 60 |
/.well-known/jwks.json, keyed jwks:{ip} |
PHLIX_HUB_RATELIMIT_ALEXA_MAX / _ALEXA_WINDOW |
60 / 60 |
Alexa skill endpoint, keyed alexa:<trusted ip> (flood-cost cap only — Amazon's RSA signature is the credential) |
PHLIX_HUB_RATELIMIT_SERVER_CLAIMS_MAX / _SERVER_CLAIMS_WINDOW |
60 / 60 |
Server-claim pairing trio, keyed server_claims:{new,status,claim}:<trusted ip> |
PHLIX_HUB_RATELIMIT_INVITE_REDEEM_MAX / _INVITE_REDEEM_WINDOW |
20 / 60 |
Invite-link redemption, keyed invite_redeem:{userId} (attributable member; bounds the burn+share write chain) |
client_relay_token (no env seam) |
10 / 60 |
Client relay-token mint (POST /me/servers/{id}/relay-token), keyed client_relay_token:{userId} — attributable member; with the service-side cap of 20 active tokens per (user, server) scope this bounds both mint rate and standing credentials (M-HUB-22, relay-token limiter). Default is a RateLimitProfiles::defaults() literal; override ONLY by adding a rate_limit.client_relay_token {max, window} subsection to config/server.php (read generically by CommonServicesProvider) |
client_request_create (no env seam) |
20 / 60 |
Media-request creation (POST /me/requests), keyed client_request_create:{userId} — attributable member; paired with the pending dedup echo and the 50-pending standing cap on the member's queue (M-HUB-23). Same override route as above: a rate_limit.client_request_create subsection in config/server.php — there is no PHLIX_HUB_RATELIMIT_* env for these two profiles |
PHLIX_HUB_RATELIMIT_RELAY_CONNECT_MAX / _RELAY_CONNECT_WINDOW |
10 / 60 |
:8802 server relay-connect (WS), keyed by IP — count=1 worker, truly global |
PHLIX_HUB_RATELIMIT_CLIENT_MOUNT_MAX / _CLIENT_MOUNT_WINDOW |
30 / 60 |
:8803 client-mount (WS), keyed by IP — count=1 worker, truly global |
PHLIX_HUB_RATELIMIT_SYNCPLAY_MOUNT_MAX / _SYNCPLAY_MOUNT_WINDOW |
30 / 60 |
:8804 SyncPlay pre-auth connect (WS), keyed syncplay_mount:<real ip> — count=1 worker, truly global; runs before the token lookup so a flood can't convert into DB work |
PHLIX_HUB_RATELIMIT_FEDERATION_CONNECT_MAX / _FEDERATION_CONNECT_WINDOW |
10 / 60 |
:8805 inbound federation connects (master role), keyed federation_connect:<trusted ip> — count=1 worker, truly global |
PHLIX_HUB_RATELIMIT_FEDERATION_FRAME_MAX / _FEDERATION_FRAME_WINDOW |
120 / 60 |
:8805 federation frames, both roles, keyed federation_frame:<remote peer hub id> — count=1 worker, truly global |
TRUSTED_PROXIES |
127.0.0.0/8,::1/128 |
Trusted proxy IPs/CIDRs used to resolve the real client address for the IP-keyed limiters (src/Common/Http/TrustedProxyResolver.php) — a forgeable leftmost X-Forwarded-For is never trusted |
PHLIX_FEDERATION_CA_BUNDLE |
unset | TLS trust-anchor selection for federation dials (this hub as leaf connecting to an https:///wss:// master): absolute path to a PEM bundle that replaces the system store as verification anchor. Set it when the master serves a private-CA certificate; an unreadable path fails LOUD at dial (never a silent system-store fallback). src/Federation/FederationPeerManager.php (buildMasterDialPlan) — see also docs/websockets.md |
HUB_UPDATES_CHECK_ENABLED |
true |
Update-check master switch. On by default: the hub fetches a version marker from raw.githubusercontent.com (below) once per poll interval. Disable with false on egress-restricted or air-gapped installs — a blocked fetch is recorded as a failed check and retried on the next interval; it never blocks boot or serves. An admin can also flip it at runtime via PUT /api/v1/admin/updates/settings (a hub_settings override, which wins over this default) |
HUB_UPDATES_MARKER_URL |
https://raw.githubusercontent.com/detain/phlix-hub/master/VERSION |
The remote version marker fetched per poll; kept in lockstep with Phlix\Hub\Version::VERSION (pinned by tests/VersionTest.php) |
HUB_UPDATES_COMMAND |
the installer's --update one-liner |
Copy-to-clipboard update command surfaced by GET /api/v1/admin/updates/status — verbatim from Updating an existing install above; an operator pastes it into a root shell |
HUB_UPDATES_POLL_SECONDS |
86400 |
Steady-state interval between marker fetches (nothing fetches on the boot path; the worker only touches the network when this interval elapsed since the last completed check) |
HUB_UPDATES_SWEEP_SECONDS |
60 |
How often the maintenance worker asks whether a poll is due — one hub_settings read, not a network call |
HUB_UPDATES_TIMEOUT_SECONDS |
10 |
Socket timeout for the marker fetch |
HUB_UPDATES_DEADLINE_SECONDS |
20 |
Backstop deadline for an in-flight fetch (NOT a duplicate of the timeout: with no route out, hooked DNS resolution can stall stream_socket_client() past the vendor client's own timer — measured 2026-08-10; without the deadline, an egress-filtered hub's first fetch stays in flight forever). At expiry the check is recorded as timed out and the single-flight guard released |
Per-worker vs global caveat. Most thresholds are enforced per worker process. The :8802/:8803/:8805 relay and federation workers are
count=1, so per-worker == global forrelay_connect/client_mount/federation_connect/federation_frame.proxy,heartbeat,jwks,alexa,server_claims,invite_redeem,client_relay_token, andclient_request_createrun acrossHUB_WORKERSHTTP workers, so their effective soft-global limit is roughlymax × HUB_WORKERS— a documented, accepted tradeoff (a strict global cap for these would require a shared store, Redis/DB, and is future work).
login,mcp, andsignupare the exceptions — they are genuinely global. Their buckets are backed by the sharedlogin_rate_limitDB table (migration040_login_rate_limit), so ALL HTTP workers share one counter per key and the 5/900, 10/900, and 3/3600 budgets are actually 5/900, 10/900, and 3/3600 — not the~max × HUB_WORKERSshape (≈20/900 with 4 workers, first 429 near attempt ~9) login had before HB-4.6 "Option B" moved credential-guessing surfaces onto the shared store.
Inbound body cap (M-HUB-30). Every HTTP connection to the hub is bounded at 1 MiB (
Application::MAX_REQUEST_BODY_BYTES): the per-connectionmaxPackageSizeis set in the HTTP worker'sonConnect(the vendored Workerman default of 10 MB would let a probe make the hub materialize 10 MB per request on PUBLIC claim endpoints), and the dispatch boundary answers an over-cap body with 413 and the registeredinvalid_payloadcode — before the maintenance gate and before any route handler reads it. Relay (:8802/:8803), SyncPlay (:8804) and federation (:8805) frames carry their own smaller documented bounds (docs/websockets.md).
Metrics (config/metrics.php)
The relay-side metrics accumulators and their MySQL rollups. These knobs tune COLLECTION;
what they feed is the admin-gated REST surface GET /api/v1/admin/metrics/
(snapshot / history / connections / routes — see src/Application.php,
registerMetricsRoutes()) — not a Prometheus scrape endpoint. The enabled flag has a
live twin in the vendored settings schema: server.metrics.enabled (restart: true — read
at boot per worker).
| Variable | Default | What it does |
|---|---|---|
PHLIX_HUB_METRICS_ENABLED |
true |
Master on/off — false no-ops every collector call (zero per-request overhead) |
PHLIX_HUB_METRICS_FLUSH_INTERVAL |
5 |
Seconds between the relay worker's in-memory→MySQL flushes |
PHLIX_HUB_METRICS_BUCKET_SECONDS |
10 |
Time-bucket granularity for rollup rows (aligned to floor(ts / N) * N) |
PHLIX_HUB_METRICS_RETENTION_DAYS |
7 |
Days of rollup rows the flush service retains before pruning |
PHLIX_HUB_METRICS_CONNECTION_TTL |
15 |
Seconds an untouched connection row is kept before pruning (live-connection view freshness) |
PHLIX_HUB_METRICS_ROUTE_CAP |
200 |
Max distinct (method, route) pairs tracked per bucket; further routes fold into one __other__ bucket so a high-cardinality URL space cannot blow up memory |
Database (config/database.php)
| Variable | Default | Description |
|---|---|---|
HUB_DB_HOST |
127.0.0.1 |
MySQL host |
HUB_DB_PORT |
3306 |
MySQL port |
HUB_DB_USER |
phlix_hub |
MySQL user |
HUB_DB_PASSWORD |
phlix_hub |
MySQL password |
HUB_DB_NAME |
phlix_hub |
MySQL database name |
DB_POOL_ENABLED |
1 |
Coroutine connection pool (each coroutine leases its own connection). 0 falls back to the single mutex-serialised socket |
DB_POOL_SIZE |
8 |
Pool size per connection name per worker — keep workers × connection names × DB_POOL_SIZE under MySQL max_connections |
Auth (config/auth.php)
| Variable | Default | Description |
|---|---|---|
HUB_JWT_SECRET |
(none in prod) | Required in production. ≥32-byte HMAC secret. A denylisted placeholder literal is refused at boot with exit 1 (R14-08 pre-flight, folded into resolveSecret() so console commands and workers enforce it too); unset = hard boot refusal (MissingJwtSecretException) outside dev, random per-process fallback inside dev (APP_ENV/HUB_ENV `dev |
HUB_JWT_ACCESS_TTL |
3600 |
Access-token lifetime (seconds) |
HUB_JWT_REFRESH_TTL |
604800 |
Refresh-token lifetime (seconds) |
Sonarr / Radarr (optional, for media requests — config/server.php)
| Variable | Default | Description |
|---|---|---|
HUB_SONARR_URL |
http://localhost:8989 |
Sonarr base URL |
HUB_SONARR_API_KEY |
(empty) | Sonarr API key |
HUB_SONARR_ENABLED |
0 |
Enable Sonarr fulfilment |
HUB_RADARR_URL |
http://localhost:7878 |
Radarr base URL |
HUB_RADARR_API_KEY |
(empty) | Radarr API key |
HUB_RADARR_ENABLED |
0 |
Enable Radarr fulfilment |
Database schema
Migrations live in migrations/ and are applied in filename order.
The numbering is historical, not contiguous — 013–026 were never used — and the
ledger is keyed by filename, so the gap is harmless.
The schema below is the complete inventory: the 35 tables the migrations create,
grouped by subsystem, plus the migrations tracking table the runner creates
for itself.
SQL-mode posture. Nothing pins or asserts sql_mode; the code is written for
MySQL 8's default. Two sites engage it: MetricsRepository::history() groups on
a SELECT alias so the default ONLY_FULL_GROUP_BY accepts it (a raw-expression
GROUP BY there 500s with error 1055), and FederationLibraryShareRepository's
offer ingestion parses every field before store — because a non-strict venue
would silently truncate oversized bytes into bounded columns as trusted rows,
which is exactly the failure the boundary parsers exist to make mode-agnostic.
A deployment running a non-default sql_mode must re-verify both paths.
| Table | Purpose |
|---|---|
| Accounts and authentication | |
users |
Hub accounts (Argon2id passwords; unique email + username) |
invite_links |
Single-use signed invite links |
login_rate_limit |
Shared, cross-worker login rate-limit buckets (one row per key; the only DB-backed limiter) |
auth_signup_guard |
First-admin election mutex — a single-row INSERT serialises concurrent registrations, so exactly one first user can ever self-elect |
auth_revoked_refresh_tokens |
Revocation registry: logouts record the jti of a presented refresh JWT, and token refresh refuses anything recorded here |
mcp_tokens |
Personal Access Tokens for the MCP endpoint (POST /mcp) — user-scoped, space-delimited scopes, SHA-256 hash only at rest |
oauth_clients |
Long-lived registry of OAuth 2.0 clients using the hub as authorization server (operator-managed) |
oauth_consent_requests |
~10-minute, single-use consent requests, consumed by the consent POST |
oauth_authorization_codes |
~60-second, single-use authorization codes, consumed by the token endpoint |
oauth_tokens |
Access/refresh grants (1 hour / 30 days) — revocable, rotated on refresh |
| Servers and libraries | |
servers |
Claimed media servers and their operational state (status, JWKS, hostname candidates) |
server_claims |
Pending/paired claim codes minted during pairing |
server_heartbeats |
Recent heartbeats for liveness and clock-skew detection |
server_libraries |
Libraries reported by servers via heartbeat |
server_library_hashes |
SHA-256 over each server's canonical library list, so heartbeats skip redundant library re-upserts when the list is unchanged |
shared_libraries |
Library grants from a server owner to another user |
library_shares |
Per-library shares with read-only / read-write levels |
dns_challenges |
DNS-01 challenge records for subdomain TLS |
| Relay | |
relay_sessions |
One row per open WebSocket relay session |
relay_user_quotas |
Per-user relay byte usage + download/upload caps + max_concurrent_streams |
relay_user_settings |
Durable per-user relay settings — the operator-configured bandwidth throttle (throttle_bps), which survives the monthly quota rollover |
client_relay_tokens |
Short-lived per-user, server-scoped relay bearer tokens — SHA-256 hash only at rest; the plaintext is returned exactly once at mint |
| Content requests | |
requests |
Jellyseerr-class request queue (pending → approved → available, or pending → rejected; approvals dispatch to Sonarr/Radarr). Note: the migration file is named 011_media_requests.sql — the table it creates is requests |
| Federation | |
federation_hubs |
This hub's own identity and role (self-referential — exactly one row) |
federation_peers |
Known peer hubs: URL, Ed25519 key, relay / admin-delegation flags, connection status — the table the :8805 handshake in docs/websockets.md requires a dialer to already appear in |
federation_sessions |
Per-peer link sessions (heartbeat stamps, byte counters) |
federation_library_shares |
Outbound library shares to peers (read / readwrite; pending / active / revoked) |
federation_incoming_share_offers |
Share offers received from a peer hub, awaiting accept or reject |
federation_admin_delegations |
Users acting with delegated admin authority on behalf of a peer hub |
| Telemetry, webhooks, settings | |
metrics_rollup |
Time-bucketed hub metrics incl. relay gauges/counters (relay_pending_requests, relay_reply_drops, relay_error_503/504, relay_cancels, latency histogram) |
metrics_route_rollup |
Per-route traffic rollups from the same in-RAM flush |
metrics_connections |
Live snapshot of currently active relay connections |
webhooks |
User-defined HTTP callbacks for phlix.* event aliases |
hub_settings |
Hub-wide typed key/value configuration |
audit_logs |
Audit trail of administrative actions |
| Runner | |
migrations |
Applied-migration ledger (filename + checksum) — created and maintained by bin/phlix migrate itself, not by a .sql file |
HTTP API
Selected endpoints below. The maintained, machine-readable surface is
openapi.yaml at the repo root (Redocly-validated, kept in lockstep
with the router by OpenApiSpecMatchesRouterTest); src/Application.php
is where routes are registered, not where they should be read from. Protected
routes require a Bearer access token — the hub is bearer-only (the legacy
cookie surface was deleted as dead code, and the old page paths 302-redirect
to /app unconditionally).
Health & discovery
| Method | Path | Notes |
|---|---|---|
GET |
/health |
Service + version JSON |
GET |
/.well-known/jwks.json |
Public JWKS |
Auth
| Method | Path | Notes |
|---|---|---|
POST |
/api/v1/auth/register |
Create account (canonical; /api/v1/auth/signup is an alias) |
POST |
/api/v1/auth/login |
Obtain access + refresh tokens |
POST |
/api/v1/auth/refresh |
Exchange a refresh token |
POST |
/api/v1/auth/logout |
Invalidate session |
GET |
/api/v1/me, /api/v1/auth/me |
Current user, incl. is_admin (protected) |
Servers
| Method | Path | Notes |
|---|---|---|
POST |
/api/v1/server-claims/new |
Server mints a claim code |
POST |
/api/v1/server-claims/claim |
User redeems a claim code (protected) |
GET |
/api/v1/me/servers |
List your servers (protected) |
DELETE |
/api/v1/me/servers/{id} |
Remove a server (protected) |
GET |
/api/v1/me/servers/{id}/access-info |
Connection info (protected) |
POST |
/api/v1/servers/{id}/heartbeat |
Server liveness (enrollment JWT) |
GET |
/api/v1/servers/{id}/info |
Server metadata (enrollment JWT) |
POST/DELETE |
/api/v1/servers/{id}/subdomain |
Allocate / revoke subdomain (enrollment JWT) |
Sharing, invites & requests
| Method | Path | Notes |
|---|---|---|
POST/GET |
/api/v1/me/shares |
Create / list library shares |
PATCH/DELETE |
/api/v1/me/shares/{id} |
Update / delete a share |
POST/GET |
/api/v1/me/invite-links |
Create / list invite links |
POST |
/api/v1/me/invite-links/{token}/redeem |
Redeem an invite link and create a library share (protected) |
GET |
/invite/{token} |
Accept an invite (public) — redirects to the SPA /app/invite/{token} acceptance page |
POST/GET |
/api/v1/me/requests |
Create / list media requests |
GET |
/api/v1/admin/requests |
Admin request queue |
POST |
/api/v1/admin/requests/{id}/approve |
Approve a request |
POST |
/api/v1/admin/requests/{id}/deny |
Deny a request |
Admin (web console)
The Vue admin console at /app/admin/* is backed by these admin-gated endpoints
([AuthMiddleware, AdminMiddleware] — 401 unauthenticated / 403 non-admin):
| Method | Path | Notes |
|---|---|---|
GET |
/api/v1/admin/dashboard/summary |
Server fleet (total/online/offline), active relay sessions, pending requests, user count |
GET |
/api/v1/admin/dashboard/activity |
Recent audit events (?limit=) |
GET/POST |
/api/v1/admin/users |
List / create accounts |
GET/PUT/DELETE |
/api/v1/admin/users/{id} |
Fetch / update / delete an account |
POST |
/api/v1/admin/users/{id}/set-admin |
Grant / revoke admin |
POST |
/api/v1/admin/users/{id}/reset-password |
Set a new password |
GET |
/api/v1/admin/logs, /logs/tail, /logs/tail-all |
Browse / tail the hub log files |
GET/PUT |
/api/v1/admin/settings |
Read / persist hub settings |
GET |
/api/v1/admin/users/{id}/bandwidth |
Read any user's current-period relay usage + caps (incl. throttle_bps) |
PUT |
/api/v1/admin/users/{id}/quota |
Set a user's download/upload byte caps + concurrent-stream cap |
PUT |
/api/v1/admin/users/{id}/throttle |
Set a user's durable relay bandwidth throttle (throttle_bps; 0 = Unlimited) |
The same logic is also reachable under /api/v1/me/* for back-compat (/me/audit-logs,
/me/logs*, /me/hub-settings, /me/federation/*).
Bandwidth & quotas
| Method | Path | Notes |
|---|---|---|
GET |
/api/v1/me/bandwidth |
Read your own current-period relay usage + caps (protected) |
Per-user relay quotas cover monthly download (quota_bytes_in) and upload (quota_bytes_out)
byte caps plus a concurrent-stream cap (max_concurrent_streams); 0 means unlimited. Over a
byte cap the relay proxy refuses with 503 quota.exceeded; over the concurrent-stream cap it
returns 503 stream.limit. The concurrent counter is in-memory per HTTP worker (soft-global
≈ max × HUB_WORKERS).
Independent of the monthly byte caps, each user also has a durable relay bandwidth throttle
(throttle_bps, migration 043_relay_user_settings) — a fixed-level rate cap (0 = Unlimited, or
1/3/5/10/20/50 Mbps; default 3 Mbps) set via PUT /api/v1/admin/users/{id}/throttle and surfaced on
both bandwidth GETs. Unlike the period-scoped quota it does not reset each month. It is enforced
by a per-connection token bucket (src/Relay/TokenBucket.php) on both relay transports (native-client
WS relay and the browser HTTP-over-relay proxy via src/Http/ConnectionResponseSink.php). See
docs/hub-admin/relay-tuning.md in
phlix-docs for the full reference.
MCP, OAuth & Alexa
| Method | Path | Notes |
|---|---|---|
POST |
/mcp |
MCP JSON-RPC (PAT or OAuth Bearer) |
GET |
/mcp |
MCP SSE stream (PAT or OAuth Bearer) |
GET/POST/DELETE |
/api/v1/me/mcp-tokens, /api/v1/me/mcp-tokens/{id} |
Mint / list / revoke PATs (protected) |
GET/POST |
/oauth/authorize |
Authorization + consent (hub session; PKCE S256 required) |
POST |
/oauth/token |
Code exchange / refresh (no hub session) |
GET |
/oauth/userinfo |
Scoped resource surface (OAuthResourceMiddleware) |
POST |
/alexa/skill |
Alexa skill endpoint (signature + certificate chain verified) |
Relay (WebSocket)
Full protocol reference in docs/websockets.md.
| Endpoint | Port | Notes |
|---|---|---|
| Server tunnel | 8802 |
Server opens its outbound tunnel here |
GET /client/{server_id} |
8803 |
Client mount — ⚠ declared unavailable: every authenticated mount is refused with close 1011 (client_mount_bridge_unavailable); live traffic rides the :8800 HTTP relay proxy |
GET /syncplay/{server_id} |
8804 |
SyncPlay client joins a synchronised-playback session |
| Federation tunnel | 8805 |
Peer hub connects for hub-to-hub shares |
Watching with family and friends
A watch party is what Phlix calls a synchronised-playback group (the protocol
messages say group, the internals say room, members see Watch Party):
press play on one device and every participant's player follows — start, pause,
seek, in step. The human journey lives in the user guide:
phlix-docs — SyncPlay
and Sharing with friends.
What the Hub adds is cross-network reach: viewers who are not on your home
LAN cannot see your media server's sync port directly, so their browsers dial the
hub's SyncPlay relay (port 8804, WebSocket path /syncplay/{server_id}) and the
hub forwards the party's play/pause/seek messages down the tunnels it already
holds. Opening the portal's Watch Party page and typing a party name is the
whole member flow — the name is the join code.
The limitation to know before you invite anyone: a hub-relayed party is account-scoped. The room is keyed to the media server and its owning account, so it spans that account's own devices (TVs, phones, laptops signed into the same hub). A friend who has their own hub account cannot join your party through the hub — the relay refuses them. Friends with accounts on the media server itself can still join through the server's own LAN/direct SyncPlay path, which does not go through the hub at all. Cross-account watch parties are a hub capability still to come; nothing in this repo ships them today.
Operators: for remote browsers to dial :8804 directly it must be reachable —
see the port table under Running alongside phlix-server and
docs/websockets.md (§:8804) for the TLS/mixed-content caveat that decides
whether opening it helps or only exposes.
Connecting a media server
Pairing is a two-sided handoff: the server requests a code from the hub, and
a human types that code into the hub while signed in. The hub's own pages
under My Servers (the SPA's claim flow, legacy path /claim-server) do the
typing; the server-side counterpart lives in the media server's admin UI.
- On the media server, open its hub-pairing screen and enter this hub's
URL. The server calls the hub's public
POST /api/v1/server-claims/newand the screen shows a claim code likeABCD-1234— uppercase, four plus four, written so no0/O/1/Ican be misread — together with an expiry countdown. - On the hub, sign in and open My Servers → claim a server, and type the code. The claim belongs to your account — whoever redeems the code owns the pairing (and is subject to the per-user server cap; a full quota fails the redeem with a clear message).
- Wait, both screens do the rest. The server polls the hub until the claim completes, then receives its enrollment JWT and registers its public key. If the code's 10-minute window lapses first, nothing is lost: ask the server for a fresh code and repeat step 2. The hub distinguishes "no such code", "expired", and "already redeemed" for the polling server.
- The server opens its own outbound relay tunnel (:8802) to the hub and starts heartbeating — no inbound ports on the server's network. It then appears under My Servers, online, and remote clients can reach it through the hub.
The tunnel's address and credentials are configured on the server side
(PHLIX_RELAY_* — see the phlix-server repository's README); the hub needs no
per-server setup beyond the claim.
Federation: pairing two hubs (operator walkthrough)
Federation links two hub installations so their users can share libraries across
hubs and (optionally) delegate admin actions. Each hub signs its handshake with the
Ed25519 key created on first boot (config/hub-signing-key.pem, README
Backing up and restoring); the paired remote hub must store the matching public
key before the link can ever verify. Everything below is also exposed in the portal —
Federation and Federation Shares pages (admin-only nav entries) — so the
curl calls are the equivalent low-level path, not the only one.
Prerequisites on both sides. The federation.enabled hub setting must be on —
while it is off, every federation control-plane route answers 409 and no dial is
attempted (Configuration reference). Each hub needs a publicly reachable :8805
listener; on TLS-managed installs that listener speaks wss:// (see
docs/websockets.md, The :8805 federation lane).
Step 1 — each admin reads their own identity. Two endpoints, both trivial:
curl -s https://YOUR-HUB/.well-known/jwks.json # → the hub's Ed25519 signing key as JWK `x` (base64url of the raw 32 bytes) curl -s -H "Authorization: Bearer $HUB_JWT" \ https://YOUR-HUB/api/v1/me/federation/hub-config # → {id, name, url, public_key, role, is_master, is_active}
id is this hub's hub_id (a UUID minted when the config row was first written).
The public_key column is operator-maintained: the handshake's hub_hello carries
exactly this stored value as the caller's claim of identity, so it must be set before
the peer pair can ever verify — if GET shows null, save the JWKS x value:
PUT /api/v1/me/federation/hub-config {"public_key": "<x>"} (the portal Federation
page does not fill this field; the API call is how).
Step 2 — exchange identity out of band, and verify it. The two admins swap
(hub_id, relay URL, public key) over a channel they already trust — a phone call, a
typed key fingerprint, anything but the hub link itself. This is the load-bearing
security law: the handshake verifies that the dialer possesses the registered key
(the proof is an Ed25519 signature checked against it), so a substituted key in
federation_peers collapses the whole ceremony to "paste the token." The URL and key
you register are only as strong as the channel you got them over. The stored key
accepts the usual encodings — base64url (the JWKS x form), standard base64, or hex;
anything that is not 32 bytes after decoding never verifies.
Step 3 — the master registers the leaf first. The dial direction is fixed: the
leaf dials the master, and the master only accepts a dialer it already knows —
the hub_id in the dial path (/relay/federation/{hub_id}) must match a peer row
before any connection is honored. So on the master:
curl -s -X POST -H "Authorization: Bearer $MASTER_JWT" -H "Content-Type: application/json" \ -d '{"url": "https://LEAF-HUB:8805", "public_key": "<LEAF_PUBLIC_KEY>", "name": "Annabell hub", "leaf_hub_id": "<LEAF_HUB_ID>"}' \ https://MASTER-HUB/api/v1/me/federation/peers
leaf_hub_id is what the path lookup matches; it may be omitted here and bound later
with PUT /api/v1/me/federation/peers/{id}/leaf-hub-id — until it is set, the leaf's
dials are refused, which is the safe failure.
Step 4 — the leaf registers the master and declares its role. On the leaf,
POST …/federation/peers with the master's URL and public key (that row is the dial
target; the handshake answers with the master's own proof-of-key before any data is
accepted), then flip this hub's role:
curl -s -X PUT -H "Authorization: Bearer $LEAF_JWT" -H "Content-Type: application/json" \ -d '{"role": "leaf", "is_active": true}' \ https://LEAF-HUB/api/v1/me/federation/hub-config
Only a config with role: "leaf" and is_active: true dials (and only rows in that
state are retried on schedule after a drop); a master hub's own config stays at
role: "master" or unset, which is what keeps it from dialing back.
Step 5 — enable what the link is allowed to carry. Peer registration alone moves
no data. Per peer: PUT /api/v1/me/federation/peers/{id}/relay {"enabled": true}
switches on library relay; PUT …/peers/{id}/admin-delegation {"enabled": true}
switches on the admin-delegation channel (further gated by named delegations,
POST /api/v1/me/federation/admin-delegations). Library sharing itself is an offer
flow: POST …/library-shares/outgoing on the offering side, then the receiving user
sees it under incoming offers and answers accept or reject.
Reading the link state. GET /api/v1/me/federation/peers shows each peer's
status; connected only ever appears after a successful hub_hello_ack ceremony —
a row stuck at pending/disconnected with a healthy process means the dial was
refused or dropped, and docs/websockets.md §:8805 documents the refusal reasons the
audit log carries.
Portal admin basics
The admin-facing tour of what the portal actually shows. Everything here lives
in the SPA at /app — no shell, no env vars required. Two roles exist:
members see their own servers, shares, and parties; admins additionally
see Federation, Federation Shares, and the whole Admin console. The
console is enforced server-side on every /api/v1/admin/* call — the menu
entry merely reflects that.
Pages every member can use
| Page | What you do there |
|---|---|
My Servers (/app/servers) |
Pair a media server (type its claim code — see Connecting a media server), inspect, rename, or unlink. This is also where you pick the current server; every media page browses that one. |
Server Detail (/app/servers/:id) |
One server's health, versions, and relay state. |
Search (/app/search) |
Cross-library search against the selected server. |
Shares (/app/shares) |
Grant other members access to your paired server's libraries. |
Shared With Me (/app/shared-with-me) |
Libraries other members have granted you. |
Invite Links (/app/invite-links) |
Create single-use signed invites — one step creates the account AND the library share; the recipient lands on the public /app/invite/:token page. |
Watch Party (/app/watch-party) |
Host or join a synchronized viewing room on your selected server. |
Requests (/app/requests) |
Jellyseerr-style request queue: ask for movies/series; admins approve. |
Security (/app/settings/security) |
Your own password change and session hygiene. |
MCP tokens (/app/mcp-tokens) |
Personal access tokens for MCP/API access — every user manages their own. |
The media-browsing surfaces (music /app/music/*, books /app/books/*,
audiobooks /app/audiobooks/*, photos /app/photo/*) all operate against the
selected server. With no server selected — or if the selected one was
unlinked — they have no data to show; pair or re-select under My Servers first.
Admin console (/app/admin/*)
| Page | What it's for |
|---|---|
| Dashboard | Hub-wide counts: users, servers, parties, traffic. |
| Users | Create users (the on-ramp when signups are closed), edit, promote/demote admin, reset a member's password, delete. |
| Logs | Recent hub log lines in the browser — no SSH needed. |
| Settings | The hub settings store — the toggles are the next table. |
| Audit Logs | Append-only record of claims, pairings, federation, and admin actions (GET /api/v1/me/audit-logs). |
| Requests (approval) | Approve or reject the request queue; approvals dispatch to Sonarr/Radarr when configured. |
What the Settings toggles actually do
Admin → Settings edits the hub settings store: live rows that override
config-file defaults, served by GET/PUT /api/v1/admin/settings. Most take
effect on the next request; the two marked restart only re-read at worker
boot. The store can never touch secrets, TLS paths, or listener ports — those
are denied by design and belong in the env reference.
| Toggle (store key) | Effect | Live? |
|---|---|---|
Signups disabled auth.signups_disabled |
POST registration answers 403 auth.signups_disabled. Invite links remain the on-ramp. Before enabling, make sure at least one admin password you know works — recovery without one is bin/phlix user:list / user:promote. |
live |
Maintenance mode hub.maintenance_mode |
Every API request answers 503 provider_unavailable EXCEPT health checks, auth endpoints (nobody gets locked out mid-maintenance), the hub/admin settings routes (so you can switch it back off), and the admin restart. Takes effect within ~1 s. |
live |
Federation enabled federation.enabled |
The hub↔hub master switch. Off: outbound dials stop and their reconnect ticks park into ≤60 s probes, all 11 federation admin mutations answer 409 provider.not_configured, inbound peer frames are dropped. On: parked probes resume dialing within a minute. |
live |
Auto-approve requests requests.auto_approve |
New requests are approved without admin review (best-effort Sonarr/Radarr dispatch). | live |
Invite default expiry invite.default_expiry_seconds |
How long a new invite link stays valid when the creator doesn't pick a lifetime. | live |
Per-user server cap server.max_servers_per_user · per-server user cap server.max_users_per_server |
Quotas enforced AT REDEEM — claiming a code or sharing past the cap fails quota.exceeded (resets on release, never a timer). |
live |
Metrics server.metrics.enabled / server.metrics.retention_days |
Opt-in usage metrics kept on the hub; retention prunes per day. | enabled: restart · retention: live |
Relay rate limits server.rate_limit (JSON) |
The real dial for the 429 throttles — env overrides for these knobs do NOT exist. | restart |
Sonarr / Radarr server.arr.{sonarr,radarr}.{enabled,url} |
Request fulfilment backends. The API keys stay env-only by design (never settable through the store). | live |
Token lifetimes auth.access_ttl · auth.refresh_ttl · server.enrollment_ttl |
JWT lifetimes for sessions and server enrollment. | live |
Troubleshooting
Symptom-first answers for the five things that actually go wrong. If none of
this fits, the log is the oracle: .logs/app.log (or Admin → Logs).
My server shows offline under My Servers
Pairing is outbound: the server keeps a WebSocket tunnel to the hub's relay listener on :8802; the hub never dials into your network. So "offline" means that tunnel is down — check the server side first:
- Is the media server process running its relay client? (phlix-server config:
PHLIX_RELAY_*— the tunnel's address and credentials are configured THERE, not on the hub.) - Can the server reach
hub-domain:8802? The hub's firewall must allow it — and note the relay listeners bind0.0.0.0regardless ofHUB_HOST(see Firewall the exposure in the manual install). - If the hub runs with
HUB_RELAY_TLS/HUB_RELAY_REQUIRE_TLS, the server must present TLS-compatible dialing — awss://hub refuses plaintext tunnels whenREQUIRE_TLSis on. - Watch the hub log for
HELLO/enrollment-key rejections: an unregistered or re-keyed server is refused, and audit-logged — check Admin → Audit Logs for the reason.
The claim code isn't going through
The code lives 10 minutes (600 s) and is single-use. The hub answers three distinct refusals, and each means something different:
| You see | Meaning | Fix |
|---|---|---|
"no such code" (claim.code_not_found, 404) |
Typo, or the code was regenerated on the server | Retype carefully (ABCD-1234, no 0/O/1/I), or get a fresh code |
"expired" (claim.code_expired, 410) |
The 10-minute window lapsed | Ask the server for a fresh code |
"already used" (claim.code_already_claimed, 409) |
Someone redeemed it | Pair the other screen — the code now belongs to that pairing |
409 quota.exceeded |
Redeeming would exceed your per-user server cap | Unlink a server or raise server.max_servers_per_user |
Guessing repeatedly rate-limits you: 429 after 60 bad attempts per minute — the bucket is per-worker, so the true ceiling is 60 × workers per IP.
Watch Party won't join — "relay on port 8804 is unreachable"
:8804 is a plaintext listener. If the portal page itself was served over
HTTPS, the browser refuses the direct wss://…:8804 dial (mixed content) —
opening 8804 in the firewall changes nothing on a TLS hub. Two working shapes:
- HTTP-served hub: open
8804/tcpand the direct dial works. - TLS-served hub: front
:8804with your reverse proxy (awss://upgrade location), and setHUB_WS_URL(+HUB_WS_PATH=/syncplay) so the portal surfaces dial the proxy instead — see Configuration reference. Note: the hub-local Watch Party page currently always dialshostname:8804regardless of that seam (open review finding, Major 38) — until it is cured, parties on TLS hubs join through the vendored Browse surfaces, not the hub's own page.
Also check the party code itself: rooms are per-server, and joining requires the caller's own access to that server (the relay gates on ownership, not just the code).
Too Many Requests (429) — what is and isn't configurable
HTTP endpoints answer 429 with Retry-After and
{"error":"Too Many Requests","code":"rate_limited"}; the WebSocket
listeners (:8802/:8803/:8804/:8805) reject the handshake with close code
1013 (Try Again Later) instead. The live dial is the server.rate_limit
hub-setting JSON blob (Admin → Settings) — restart required after edits,
because limiter profiles are per-worker singletons. There are no
PHLIX_HUB_RATELIMIT_* env knobs (a doc fiction retired in Major 8). A
single NAT behind many clients shares one IP bucket — raise the bucket or
front-load, don't fight the throttle.
Passwords: members and admin lockout
There is no self-service reset — no mail transport exists to send a
reset link. A member who forgets their password asks an admin: Admin →
Users → reset-password (POST /api/v1/admin/users/{id}/reset-password).
The admin who locked everyone else out (or was demoted) is recovered from the
shell: bin/phlix user:list to find the account, bin/phlix user:promote 'username-or-email' to restore admin (it toggles — --revoke demotes,
refusing to remove the last admin). If the LAST admin disables themselves behind
auth.signups_disabled, the CLI path remains open — it needs no web session.
Testing & quality
composer test # PHPUnit (Unit + Integration suites) composer cs # PHP_CodeSniffer (PSR-12) via scripts/assert-phpcs-corpus.php composer stan # PHPStan (level 9) composer psalm # Psalm (errorLevel 1) ./vendor/bin/phpunit --testsuite E2E # real MCP client SSE session (needs a booted hub + MySQL)
Running the Integration suite (real MySQL)
tests/Integration/** talks to a real database and self-skips when the
HUB_TEST_DB_* variables are absent, so composer test on a box without MySQL
runs the unit suite only. Point them at a throwaway database — the suite builds
its tables there and empties data between tests, so never
aim them at a database you care about:
HUB_TEST_DB_HOST=127.0.0.1 HUB_TEST_DB_PORT=3306 \ HUB_TEST_DB_USER=root HUB_TEST_DB_PASSWORD=root HUB_TEST_DB_NAME=phlix_hub_test \ ./vendor/bin/phpunit --testsuite Integration
The named database must already exist (the tests create tables, not schemas).
The migration chain is applied once per PHPUnit process (a few seconds),
not per test: RealDatabaseTestCase caches the resulting schema fingerprint,
re-validates it against information_schema on every setUp() (~2 ms; any
drift — including a sibling suite legitimately emptying the database — forces
a drop-and-re-apply), and each test only gets a cheap 0.02 s chain replay plus
a data reset on top. Budget seconds for the suite, not minutes per test.
CI runs on every push and pull request via GitHub Actions — twelve jobs — and the image pair ships in a second workflow (docker.yml). The full set, honestly enumerated (M-HUB-20: this list previously claimed eight checks and omitted four jobs, the whole Docker workflow, and the nightly trigger):
- Composer Validation (
composer-validate) —composer validate --strict. - OpenAPI Description (
openapi, S66) — Redocly (version-pinned) lintsopenapi.yamlfor structural validity only; whether the spec still matches the route table is a different question, answered byOpenApiSpecMatchesRouterTestinside the phpunit job. - Server Route Snapshot Currency (
snapshot-currency) — the vendored route snapshot'ssource_shamust still resolve against phlix-server master (scripts/check-route-snapshot-currency.php). - PHP CodeSniffer (PSR-12) (
phpcs) — asserts every security-path PHP extension is loaded (S258), then runs PHPCS over the whole corpus with an asserted file count (S299). - PHPStan (
phpstan) — level 9. - Psalm (
psalm) — errorLevel 1. - PHPUnit (
phpunit) — provisions amysql:8.0service, applies the migration chain to it, and runs both suites through the S458 parallel runner (coverage uploaded to Codecov). The suite output then faces the S173 zero-skip gate —scripts/assert-integration-tests-ran.phpreads PHPUnit's JUnit report and fails the build if any test skipped without an explicit, justifiedALLOWED_SKIPSentry (to date exactly one: the no-swoole no-op case inCoroutineHooksTest, whose branch is unreachable on the swoole-loading CI environment), or if fewer than 31 real-database tests executed — 31 is the enforced MIN floor, not the suite size (the current real-DB count is printed by the gate itself). ⚠ A skipped test is not a neutral absence: PHPUnit exits0with "OK, but some tests were skipped!", so before this gate a missing MySQL service made every integration test — 31 at the time — skip on every run while the check stayed green. - Security Audit (
composer-audit) — Composer dependency audit. - Real MCP client SSE session (
mcp-e2e, S329) — boots the real hub on:8800against the MySQL service, mints PATs through the realMcpTokenService, and drives the official@modelcontextprotocol/sdkclient through initialise → tools/list → one tool call → clean close. A JUnit gate (scripts/assert-mcp-e2e-ran.php) fails when any required case is absent, skipped, or asserted nothing — never a bare green. - Cross-Repo Path Contract (
cross-repo-paths) — paths that sibling repositories cite into this tree still exist here. - UI Pin Skew (
ui-pin-skew, S181) — compares the@phlix/*pins this repo and its sibling consumers resolved to. Because pins go stale on OTHER repositories' clocks, this workflow additionally runs on a nightlyschedule(17 3 * * *) — a check that only fires when this repo moves misses its own defect class. The schedule is workflow-level, so a nightly run re-executes all twelve jobs unchanged. - SPA Bundle Build + Compare Gate (
spa-bundle, S253) — a fresh build ofweb-ui/on the pinned Node must byte-match the committedpublic/assets/app/tree. This is the gate that makes "what you serve is what's in git" true.
The enumeration above is the twelve. One further build posture sits under the
same heading by habit but is not a CI job — the browser floor (M-HUB-10):
the portal bundle targets build.target: 'baseline-widely-available'
(≈Chrome/Edge 107+, Safari 16+, Firefox 104+), DECLARED in
web-ui/vite.config.ts rather than inherited from a Vite default. The served
shell carries a boot placeholder + <noscript> notice, so a below-floor
browser reads an explanation instead of a white screen. TV surfaces are
separate packaged clients and never load this bundle.
The Docker workflow (S300) builds this repo's own image on this repo's PRs: boot first,
publish second — docker-boot-gate boots the freshly built image against a throwaway
MySQL and asserts it actually serves /health (a docker build never executes
ENTRYPOINT, so build-green alone once shipped an image that exited 2 on docker run),
and only then does docker-build publish it.
Project structure
phlix-hub/
├── bin/ # bin/phlix CLI entry script (webman/console)
├── config/ # Environment-driven config (auth, database, federation, hub, invite, logger, metrics, process, requests, server, updates)
├── docker/ # docker-entrypoint.sh + php.ini (image bits; the image boots start.php directly)
├── docs/ # websockets.md — the :8802/:8803/:8804/:8805 surfaces
├── migrations/ # Idempotent SQL migrations
├── openapi.yaml # HTTP surface description (linted with redocly.yaml)
├── public/
│ └── assets/ # Static files + built SPA — public/ holds NO index.php; the daemon entry
│ # point is start.php at the repo root
├── scripts/
│ ├── run-migrations.php
│ └── assert-*.php # CI gates (phpcs corpus, coverage, integration + MCP E2E currency)
├── src/
│ ├── Application.php # Worker bootstrap + route registration
│ ├── Alexa/ # Skill envelope, signature/certificate-chain verification, media gateway
│ ├── Auth/ # JWT, users, auth manager
│ ├── Common/ # Container, database pool, logging, rate limiting, trusted-proxy resolver
│ ├── Console/ # bin/phlix commands (migrate, smoke:jwt, oauth:client:*)
│ ├── Http/ # Router, request/response, controllers, middleware, response sink
│ ├── Hub/ # Claims, heartbeats, sharing, DNS, TLS, relay sessions, update checks
│ ├── Federation/ # Hub-to-hub federation peers, shares, sessions
│ ├── Mcp/ # MCP protocol, tool registry + tools, PAT service, scopes
│ ├── OAuth/ # Authorization server: clients, codes, PKCE, tokens, consent, scopes
│ ├── Relay/ # Reverse-tunnel relay workers, frame codec, tunnels, throttle token bucket
│ ├── Requests/ # Media request manager
│ ├── Stats/ # Relay metrics registry, collector, flush service
│ └── SyncPlay/ # SyncPlay relay worker + pending-command dispatch
├── start.php # Workerman daemon entry point — `php start.php start` (stop/restart/reload/status)
├── tests/ # PHPUnit Unit + Integration + E2E suites
├── web-ui/ # Vite + TypeScript SPA (@phlix/hub-web-ui), built to public/assets/app/
└── Dockerfile
Related repositories
detain/phlix(a.k.a.phlix-server) — the local media server.detain/phlix-docs— the user-facing docs site, incl. the Hub journey pages linked from Do I actually need a hub?detain/phlix-shared— shared interfaces, DTOs, and protocol types.detain/phlix-ui— the Vue design system whose bundle this hub serves at/app.detain/phlix-contracts— the frozen error-code and route-manifest contracts shared by every Phlix client and server.detain/phlix-syncplay— the watch-party wire spec and reference libraries.
License
Mozilla Public License 2.0 — see LICENSE for the full text.
MPL-2.0 is per-file weak copyleft: you may run, modify, self-host and embed the Hub in a larger project, including a commercial one, but if you modify a file that is part of the Hub you must make that file's source available under MPL-2.0. Files you add alongside it can be licensed however you like.
This matches phlix-server, since both
are server-side applications. The interop layer stays MIT on purpose —
phlix-shared,
phlix-contracts,
phlix-tokens and
phlix-syncplay are consumed by
third-party clients and plugins, where copyleft would reach into every consumer.
"Phlix" is a trademark; see TRADEMARK.md for what that does and doesn't restrict. Attribution is appreciated, not required.
Note that crell/tukio (the event dispatcher) is LGPL-3.0-or-later. MPL-2.0
lists the GPL family as Secondary Licenses (§1.12, §3.3), so it is compatible;
redistributors must still meet each dependency's own terms.