{"id":51118871,"url":"https://github.com/detain/phlix-hub","last_synced_at":"2026-06-25T00:30:25.002Z","repository":{"id":358420838,"uuid":"1241017555","full_name":"detain/phlix-hub","owner":"detain","description":"Central cloud directory + reverse-tunnel relay for Phlix media servers. Sign in once, reach any of your servers from anywhere. Self-hostable.","archived":false,"fork":false,"pushed_at":"2026-06-21T02:44:39.000Z","size":5422,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-06-21T03:07:46.391Z","etag":null,"topics":["dashboard","emby","jellyfin","jwt","ldap","media-hub","media-server","oidc","php","php8","plex","relay","remote-access","reverse-tunnel","self-hosted","sso","webhooks","websocket","workerman"],"latest_commit_sha":null,"homepage":"https://phlex.media","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/detain.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-05-16T21:26:31.000Z","updated_at":"2026-06-21T02:44:41.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/detain/phlix-hub","commit_stats":null,"previous_names":["detain/phlex-hub","detain/phlix-hub"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/detain/phlix-hub","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/detain%2Fphlix-hub","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/detain%2Fphlix-hub/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/detain%2Fphlix-hub/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/detain%2Fphlix-hub/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/detain","download_url":"https://codeload.github.com/detain/phlix-hub/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/detain%2Fphlix-hub/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34755061,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-24T02:00:07.484Z","response_time":106,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["dashboard","emby","jellyfin","jwt","ldap","media-hub","media-server","oidc","php","php8","plex","relay","remote-access","reverse-tunnel","self-hosted","sso","webhooks","websocket","workerman"],"created_at":"2026-06-25T00:30:24.307Z","updated_at":"2026-06-25T00:30:24.985Z","avatar_url":"https://github.com/detain.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Phlix Hub\n\n**Central cloud directory + reverse-tunnel relay for [Phlix](https://github.com/detain/phlix) media servers.**\nSign in once, reach any of your servers from anywhere — no port forwarding, no static IP, no VPN. Fully self-hostable.\n\n[![CI](https://github.com/detain/phlix-hub/actions/workflows/ci.yml/badge.svg)](https://github.com/detain/phlix-hub/actions/workflows/ci.yml)\n[![codecov](https://codecov.io/gh/detain/phlix-hub/graph/badge.svg)](https://codecov.io/gh/detain/phlix-hub)\n[![PHP](https://img.shields.io/badge/PHP-8.3%2B-777bb4?logo=php\u0026logoColor=white)](https://www.php.net/)\n[![PHPStan](https://img.shields.io/badge/PHPStan-level%209-brightgreen)](https://phpstan.org/)\n[![Psalm](https://img.shields.io/badge/Psalm-level%201-brightgreen)](https://psalm.dev/)\n[![Code style](https://img.shields.io/badge/code%20style-PSR--12-blueviolet)](https://www.php-fig.org/psr/psr-12/)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)\n\n---\n\n## Table of contents\n\n- [What is the Hub?](#what-is-the-hub)\n- [Features](#features)\n- [Architecture](#architecture)\n- [Requirements](#requirements)\n- [One-line install](#one-line-install)\n- [Updating an existing install](#updating-an-existing-install)\n- [Uninstalling](#uninstalling)\n- [Running alongside phlix-server](#running-alongside-phlix-server)\n- [Quick start (development)](#quick-start-development)\n- [Production install on Ubuntu](#production-install-on-ubuntu)\n  - [1. System packages](#1-system-packages)\n  - [2. MySQL: database, user, and grants](#2-mysql-database-user-and-grants)\n  - [3. Application code](#3-application-code)\n  - [4. Environment configuration](#4-environment-configuration)\n  - [5. Run migrations](#5-run-migrations)\n  - [6. Run as a systemd service](#6-run-as-a-systemd-service)\n  - [7. Reverse proxy \u0026 TLS](#7-reverse-proxy--tls)\n- [Docker](#docker)\n- [Configuration reference](#configuration-reference)\n- [Database schema](#database-schema)\n- [HTTP API](#http-api)\n- [Connecting a media server](#connecting-a-media-server)\n- [Testing \u0026 quality](#testing--quality)\n- [Project structure](#project-structure)\n- [Related repositories](#related-repositories)\n- [License](#license)\n\n---\n\n## What is the Hub?\n\nA Phlix media server normally lives on your home network behind NAT. The **Hub** is the small,\nself-hostable cloud service that makes those servers reachable from anywhere:\n\n- Each server **claims** itself to the Hub once (a short pairing code), then holds an outbound\n  **WebSocket reverse tunnel** open to the Hub.\n- Remote clients (apps, browsers) connect to the Hub, which **relays** their traffic down the\n  tunnel to the right server — so the server never needs an inbound port open.\n- The Hub is also a **directory**: it tracks which servers you own, their liveness (heartbeats),\n  shared libraries, invite links, and media requests.\n\nYou can use the public Hub or run your own — the same codebase powers both.\n\n## Features\n\n- **Accounts \u0026 auth** — signup/login, Argon2id password hashing, HMAC-SHA256 JWT access \u0026\n  refresh tokens, and a published JWKS endpoint. The first account created is auto-promoted to admin.\n- **Server claiming** — short-lived claim codes, enrollment JWTs, and a server registry with\n  per-server public keys (JWK).\n- **Reverse-tunnel relay** — servers hold an outbound WebSocket to the Hub; remote clients are\n  multiplexed down that tunnel over a compact binary frame protocol. Idle tunnels are reaped and\n  liveness is tracked with heartbeats.\n- **Subdomain allocation** — each enrolled server can be assigned `\u0026lt;subdomain\u0026gt;.\u0026lt;public-domain\u0026gt;`\n  for clean, per-server URLs.\n- **Library sharing** — share a specific library on one of your servers with another Hub user,\n  with read-only or read/write permission levels.\n- **Invite links** — single-use, signed invite links that grant library access to a recipient.\n- **Hub-to-hub federation** — federate with peer hubs to share libraries across hubs, with\n  per-peer sessions and admin delegation.\n- **Media requests** — a Jellyseerr-class request queue. Users request movies/series; admins\n  approve, and the Hub talks to Sonarr/Radarr to fulfil them.\n- **Web UI \u0026 admin console** — the hub's front door is a Vue SPA (the shared `@phlix/ui` design\n  system) served at `/app` (`/` redirects to `/app/servers`): My Servers, Federation, and Shares for\n  every signed-in user, plus a `requiresAdmin` **admin console** at `/app/admin/*` (Hub Dashboard,\n  Users, Logs, Settings, Audit Logs). The original Smarty pages still resolve as a legacy fallback.\n  Everything is backed by a full JSON API under `/api/v1` (incl. `/api/v1/admin/*`).\n- **Operations-ready** — structured JSON logging (Monolog) across dedicated channels\n  (app, error, hub, relay, audit), a `/health` endpoint, and idempotent SQL migrations.\n\n## Architecture\n\nThe Hub runs as a set of long-lived [Workerman](https://www.workerman.net/) workers in a single\nprocess group:\n\n| Worker | Default port | Purpose |\n|--------|--------------|---------|\n| HTTP | `8800` | REST API + the Vue SPA (`/app`) + legacy SSR pages + `/health` |\n| Relay (server-facing) | `8802` | Servers connect here to open their outbound tunnel |\n| Relay (client-facing) | `8803` | Remote clients connect (`GET /client/{server_id}`) and are routed down a tunnel |\n\nSupporting pieces:\n\n- **PSR-11 container** (PHP-DI 7) wires services; routes are registered in\n  [`src/Application.php`](src/Application.php).\n- **MySQL** is accessed through an async connection pool (`workerman/mysql`). The pool is\n  initialised lazily so `/health` stays up even if the database is briefly unreachable.\n- **JWT** auth is symmetric (HS256); Ed25519 keys are used for signing enrollment/relay material,\n  and the public key set is served at `/.well-known/jwks.json`.\n\n## Requirements\n\n- **PHP 8.3+** with the `pcntl`, `posix`, `json`, `mbstring`, `curl`, and `sodium` extensions\n- **MySQL 8.0+** (or MariaDB 10.6+)\n- **Composer 2**\n- A POSIX host (Linux recommended; Workerman uses `pcntl`/`posix` for process management)\n\n## One-line install\n\nOn a fresh Ubuntu/Debian host, [`scripts/install.sh`](scripts/install.sh) does everything in\n[Production install](#production-install-on-ubuntu) for you — system packages, MySQL database +\nuser, code, env file, JWT secret, migrations, a systemd service, and an HAProxy reverse proxy\nwith an auto-renewing Let's Encrypt certificate:\n\n\u003e The installer also compiles the **Swoole + php-uv** extensions from source (the coroutine\n\u003e runtime Workerman uses), idempotently skipping the build when they already load, and runs a\n\u003e `disable_functions` preflight — see\n\u003e [Swoole \u0026 php-uv on Linux](https://detain.github.io/phlix-docs/install/linux#swoole-php-uv-coroutine-runtime).\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/detain/phlix-hub/master/scripts/install.sh | sudo bash\n```\n\nTo set up HTTPS at the same time, pass your domain and an email for Let's Encrypt:\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/detain/phlix-hub/master/scripts/install.sh \\\n  | sudo bash -s -- --domain hub.example.com --admin-email you@example.com\n```\n\nThe script prompts for the install path, database user/password, and hostname when run in a\nterminal (with sensible defaults), and runs **fully unattended** when piped or given `-y`. See\n`sudo bash scripts/install.sh --help` for every flag. Prefer to do it by hand? Follow the\n[step-by-step guide](#production-install-on-ubuntu) below.\n\n### Install flags\n\n`sudo bash scripts/install.sh --help` lists every option. The most useful:\n\n| Flag | Effect |\n|---|---|\n| `--domain HOST` | Public hostname for the hub (enables TLS when paired with `--admin-email`) |\n| `--admin-email EMAIL` | Email registered with Let's Encrypt |\n| `--db-name`, `--db-user`, `--db-pass`, `--db-host`, `--db-port` | MySQL identity (random password if `--db-pass` omitted) |\n| `--jwt-secret SECRET` | HMAC secret used to sign JWTs (random 32-byte hex if omitted) |\n| `--service-user USER` | System user to run as (default `phlix-hub` — dedicated system account, created if missing) |\n| `--workers N` | HTTP worker processes (default 4) |\n| `--branch NAME` | Git branch or tag to install (default `master`) |\n| `--repo URL` | Git repository URL (default `detain/phlix-hub`) |\n| `--tls` / `--no-tls` | Force or skip Let's Encrypt + HAProxy TLS |\n| `--no-proxy` | Skip the managed HAProxy entirely (use your own reverse proxy) |\n| `--update` | Pull new code + run migrations on an existing install (preserves env + secrets) |\n| `--uninstall` | Remove the install — interactive prompts before each destructive step |\n| `--purge` | With `--uninstall`, also drop the DB, delete the Let's Encrypt cert, and remove the dedicated system user |\n| `-y`, `--non-interactive` | Never prompt; use defaults/flags |\n| `--interactive` | Force prompts even when piped |\n\n\u003e Default service user changed from `www-data` to `phlix-hub` so the hub runs under\n\u003e its own dedicated system account, isolated from the apache/nginx-owned `www-data`.\n\u003e Existing installs that were created on `www-data` keep running on `www-data` —\n\u003e `--update` reads `User=` from the systemd unit rather than rewriting it.\n\n## Updating an existing install\n\nThe same `scripts/install.sh` updates an in-place install **without rotating any secrets**. It\nreads the existing `/etc/phlix-hub.env` (so the JWT secret and DB password are preserved),\npulls the latest code, refreshes Composer dependencies, runs pending migrations, and restarts\nthe service:\n\n```bash\nsudo bash /opt/phlix-hub/scripts/install.sh --update -y\n```\n\nOr via the one-liner:\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/detain/phlix-hub/master/scripts/install.sh \\\n  | sudo bash -s -- --update -y\n```\n\nPin to a specific tag or branch with `--branch`:\n\n```bash\nsudo bash /opt/phlix-hub/scripts/install.sh --update --branch v0.2.0 -y\n```\n\nWhat `--update` does, in order:\n\n1. Discovers the install path from the systemd unit's `WorkingDirectory` (so non-default\n   `--install-path` setups are detected automatically).\n2. Reads `/etc/phlix-hub.env` and reuses every value — `HUB_JWT_SECRET`, `HUB_DB_PASSWORD`,\n   `HUB_PUBLIC_DOMAIN`, etc. are never regenerated.\n3. `git fetch --depth 1 origin $BRANCH` then `git reset --hard origin/$BRANCH` in the install\n   directory. Uncommitted local edits are **discarded** — the script warns first.\n4. `composer install --no-dev --optimize-autoloader` (follows `composer.lock`).\n5. Clears `var/smarty/{compile,cache}` to avoid stale compiled templates.\n6. Runs `scripts/run-migrations.php` — idempotent, only pending migrations apply.\n7. `systemctl daemon-reload` then `systemctl restart phlix-hub`.\n8. `curl http://localhost:$HUB_PORT/health` as a final sanity check.\n\nWhat it explicitly does **not** touch: the env file, MySQL grants, HAProxy config, or the\nLet's Encrypt certificate. If a release adds new `HUB_*` env vars, append them to\n`/etc/phlix-hub.env` yourself — anything the code expects but doesn't find falls back to its\ndocumented default.\n\n## Uninstalling\n\n`scripts/install.sh --uninstall` removes an existing install. By default it is **interactive**\nand prompts separately before each destructive step. The MySQL database and the Let's Encrypt\ncertificate are **kept** unless you opt in explicitly:\n\n```bash\nsudo bash /opt/phlix-hub/scripts/install.sh --uninstall\n```\n\nAdd `--purge` to also drop the database (and user) and delete the Let's Encrypt certificate\nvia `certbot delete`. Combine with `-y` for a fully unattended teardown:\n\n```bash\nsudo bash /opt/phlix-hub/scripts/install.sh --uninstall --purge -y\n```\n\nPiped, non-interactive runs require an explicit `-y` to proceed.\n\nWhat it removes, only if it finds them:\n\n1. The `phlix-hub` systemd service — `stop`, `disable`, remove the unit, `daemon-reload`.\n2. HAProxy fragment at `/etc/haproxy/phlix-managed/phlix-hub.cfg.fragment`, and\n   `/etc/haproxy/haproxy.cfg` is rebuilt. If phlix-server is still installed, its frontend\n   and backend stay. If phlix-hub was the last Phlix project, the pre-Phlix snapshot at\n   `/etc/haproxy/haproxy.cfg.pre-phlix.bak` is restored (or `haproxy.cfg` is removed and\n   haproxy is stopped + disabled if no snapshot exists).\n3. The combined PEM at `/etc/haproxy/certs/\u003cdomain\u003e.pem`.\n4. `/etc/cron.d/phlix-hub-certbot` and `/etc/letsencrypt/renewal-hooks/deploy/phlix-haproxy.sh`.\n5. The Let's Encrypt cert via `certbot delete --cert-name \u003cdomain\u003e` — only with `--purge` or\n   interactive confirmation.\n6. The MySQL database and dedicated user — only with `--purge` or interactive confirmation.\n7. The install directory (`rm -rf`, with a denylist of system paths like `/`, `/etc`, `/opt`).\n8. `/etc/phlix-hub.env` (env file).\n9. The dedicated system user (`phlix-hub` or whatever `User=` the systemd unit was using) via\n   `userdel` — only with `--purge` or interactive confirmation. Refuses to touch shared OS\n   accounts (`www-data`, `root`, `daemon`, etc.). Cross-detects phlix-server's systemd unit\n   and refuses to remove a user that's still being used by the sibling service.\n\nSystem packages (`php-*`, `mysql-server`, `haproxy`, `certbot`) and `ufw` rules are left in\nplace — uninstall them yourself with `apt remove` / `ufw delete` if you no longer need them.\n\n## Running alongside phlix-server\n\nBoth installers can share a single HAProxy instance — they auto-merge into one\n`/etc/haproxy/haproxy.cfg`. Just run both installers normally; whichever runs second detects\nthe first's fragment and rebuilds a combined config that routes by `Host:` header.\n\n```bash\n# 1. Install phlix-hub first (with TLS).\ncurl -fsSL https://raw.githubusercontent.com/detain/phlix-hub/master/scripts/install.sh \\\n  | sudo bash -s -- --domain hub.example.com --admin-email you@example.com -y\n\n# 2. Install phlix-server, also with TLS, on a different hostname.\ncurl -fsSL https://raw.githubusercontent.com/detain/phlix-server/master/scripts/install.sh \\\n  | sudo bash -s -- --domain phlix.example.com --admin-email you@example.com -y\n```\n\nAfter both finish, `/etc/haproxy/haproxy.cfg` is a Phlix-managed config carrying both\nprojects' frontends and backends, with HAProxy picking the right cert per SNI hostname from\n`/etc/haproxy/certs/`.\n\n**How the merge works.** Each install drops a fragment at\n`/etc/haproxy/phlix-managed/\u003cproject\u003e.cfg.fragment` with `fe_http`, `fe_https`, and `backends`\nsections. A shared rebuilder then assembles the final `haproxy.cfg` from every fragment it\nfinds. HAProxy's `crt /etc/haproxy/certs/` directive auto-loads every `.pem` in that directory\nand picks the right one per SNI hostname.\n\nThe first install snapshots any pre-Phlix `haproxy.cfg` to\n`/etc/haproxy/haproxy.cfg.pre-phlix.bak`.\n\n**Uninstall behaviour**: `--uninstall` removes only that project's fragment and rebuilds. If\nother Phlix projects remain, their frontend stays untouched. When the **last** Phlix project\nis uninstalled, the rebuilder restores the pre-Phlix snapshot (or removes `haproxy.cfg`\noutright if there was no pre-Phlix config) and stops/disables `haproxy`.\n\nThe **hub server-tunnel port** (`:8802`) is a separate listener — servers connect to that port\ndirectly. Open it on the firewall but don't put it behind the HAProxy 80/443 frontend.\n\nIf you'd rather use your own reverse proxy (nginx, Caddy, Traefik, etc.) instead of the\nmanaged HAProxy, pass `--no-proxy` to either install script. Each service then listens on its\nown port (8800 / 8802 / 8803 for phlix-hub, 8096 for phlix-server) and you point your proxy\nat those.\n\nEverything else is already namespaced (env files, systemd units, install dirs, service users,\nMySQL DBs, backend ports, certbot artefacts) so there are no other co-install conflicts.\n\n## Quick start (development)\n\n```bash\ngit clone https://github.com/detain/phlix-hub.git\ncd phlix-hub\ncomposer install\n\n# Point the Hub at a MySQL instance (see Configuration reference below).\nexport HUB_DB_HOST=127.0.0.1 HUB_DB_USER=phlix_hub HUB_DB_PASSWORD=phlix_hub HUB_DB_NAME=phlix_hub\nexport HUB_JWT_SECRET=\"$(openssl rand -hex 32)\"\n\nphp scripts/run-migrations.php     # create the schema (idempotent)\nphp public/index.php start         # start the Hub (Ctrl-C to stop)\n\ncurl http://localhost:8800/health  # =\u003e {\"status\":\"ok\",...}\n```\n\nThen open \u003chttp://localhost:8800/signup\u003e to create the first account (auto-promoted to admin).\nAfter signing in, `/my-servers` lists your servers and `/claim-server` walks through pairing a\nnew one.\n\n\u003e Run `php public/index.php start -d` to daemonize; `stop`, `restart`, `reload`, and `status`\n\u003e are also available.\n\n### CLI (`bin/phlix`)\n\nA small [`webman/console`](https://www.workerman.net/doc/webman/components/command.html) CLI\nships at `bin/phlix`:\n\n```bash\nphp bin/phlix list         # list available commands (works with no database)\nphp bin/phlix migrate      # apply migrations/*.sql (idempotent; tracking table)\nphp bin/phlix smoke:jwt    # smoke-test the JWT create/validate round-trip\n```\n\n`migrate` is the CLI equivalent of `php scripts/run-migrations.php`.\n\n## Production install on Ubuntu\n\nThese steps target **Ubuntu 22.04 / 24.04**. Run as a sudo-capable user.\n\n### 1. System packages\n\n```bash\n# PHP: use the version-agnostic php-* package names so apt installs the\n# distro's current PHP. Ubuntu 24.04 ships PHP 8.3 by default, which meets\n# the Hub's requirement.\nsudo apt update\nsudo apt install -y \\\n  php-cli php-mysql php-mbstring php-curl \\\n  php-xml php-bcmath php-gd php-zip \\\n  git unzip mysql-server\n\nphp -v   # confirm PHP 8.3 or newer\n\n# Composer\ncurl -sS https://getcomposer.org/installer | php\nsudo mv composer.phar /usr/local/bin/composer\n```\n\n\u003e `pcntl`, `posix`, and `sodium` ship with the `php-cli` package on Ubuntu — verify with\n\u003e `php -m | grep -E 'pcntl|posix|sodium'`. If your distro's default PHP is older than 8.3,\n\u003e upgrade to Ubuntu 24.04 (or newer) rather than pulling in a third-party PHP build.\n\n### 2. MySQL: database, user, and grants\n\nSecure the server first (sets the root password, removes anonymous users, etc.):\n\n```bash\nsudo mysql_secure_installation\n```\n\nThen create the database and a dedicated, least-privilege user. Open a root shell with\n`sudo mysql` and run:\n\n```sql\n-- Database (utf8mb4 throughout)\nCREATE DATABASE phlix_hub\n  CHARACTER SET utf8mb4\n  COLLATE utf8mb4_unicode_ci;\n\n-- Dedicated user. The Hub connects over TCP to 127.0.0.1 by default, so the\n-- user host must match. Use a strong, unique password.\nCREATE USER 'phlix_hub'@'127.0.0.1' IDENTIFIED BY 'CHANGE-ME-strong-password';\n\n-- Least privilege: only the rights the app needs on its own schema.\nGRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, REFERENCES\n  ON phlix_hub.* TO 'phlix_hub'@'127.0.0.1';\n\nFLUSH PRIVILEGES;\n```\n\nNotes:\n\n- The migration runner issues `CREATE TABLE` / `ALTER TABLE`, so `CREATE`, `ALTER`, and `INDEX`\n  are required in addition to the CRUD grants.\n- If the Hub runs on a **different host** than MySQL, create the user for that host (or `'%'`)\n  and set `HUB_DB_HOST` accordingly. Make sure MySQL's `bind-address` allows remote connections.\n- MySQL distinguishes `'localhost'` (unix socket) from `'127.0.0.1'` (TCP). The Hub uses TCP, so\n  grant to `'127.0.0.1'`. To also allow socket logins for manual `mysql` use, create a second\n  `'phlix_hub'@'localhost'` user.\n\nVerify the credentials work:\n\n```bash\nmysql -h 127.0.0.1 -u phlix_hub -p phlix_hub -e 'SELECT 1;'\n```\n\n### 3. Application code\n\n```bash\nsudo git clone https://github.com/detain/phlix-hub.git /opt/phlix-hub\ncd /opt/phlix-hub\nsudo composer install --no-dev --optimize-autoloader\nsudo mkdir -p .logs\nsudo chown -R www-data:www-data /opt/phlix-hub\n```\n\n### 4. Environment configuration\n\nThe Hub is configured entirely through environment variables (see the\n[reference](#configuration-reference)). Create an env file the service will load:\n\n```bash\nsudo tee /etc/phlix-hub.env \u003e/dev/null \u003c\u003c'EOF'\nHUB_HOST=0.0.0.0\nHUB_PORT=8800\nHUB_WORKERS=4\nHUB_PUBLIC_DOMAIN=hub.example.com\n\nHUB_DB_HOST=127.0.0.1\nHUB_DB_PORT=3306\nHUB_DB_USER=phlix_hub\nHUB_DB_PASSWORD=CHANGE-ME-strong-password\nHUB_DB_NAME=phlix_hub\n\n# REQUIRED in production: a \u003e=32-byte secret. Generate once and keep stable.\nHUB_JWT_SECRET=CHANGE-ME-run-openssl-rand-hex-32\nEOF\nsudo chmod 600 /etc/phlix-hub.env\n```\n\nGenerate a secret with `openssl rand -hex 32`. If `HUB_JWT_SECRET` is unset the Hub falls back to\na random per-process secret — fine for dev, but it invalidates every token on restart, so it must\nbe set in production.\n\n### 5. Run migrations\n\n```bash\nsudo -u www-data --preserve-env \\\n  env $(grep -v '^#' /etc/phlix-hub.env | xargs) \\\n  php /opt/phlix-hub/scripts/run-migrations.php\n```\n\nThe runner records applied migrations in a `migrations` table and is **idempotent** — re-running\nit after a successful apply is a no-op.\n\n### 6. Run as a systemd service\n\n```bash\nsudo tee /etc/systemd/system/phlix-hub.service \u003e/dev/null \u003c\u003c'EOF'\n[Unit]\nDescription=Phlix Hub\nAfter=network.target mysql.service\n\n[Service]\nType=simple\nUser=www-data\nGroup=www-data\nEnvironmentFile=/etc/phlix-hub.env\nWorkingDirectory=/opt/phlix-hub\nExecStart=/usr/bin/php /opt/phlix-hub/public/index.php start\nRestart=on-failure\nRestartSec=5\n\n[Install]\nWantedBy=multi-user.target\nEOF\n\nsudo systemctl daemon-reload\nsudo systemctl enable --now phlix-hub\nsudo systemctl status phlix-hub\ncurl http://localhost:8800/health\n```\n\n### 7. Reverse proxy \u0026 TLS\n\nTerminate TLS at a reverse proxy in front of the Hub. Both the HTTP API (`8800`) and the\nclient-facing relay (`8803`, WebSocket) need to be reachable. Example nginx server block:\n\n```nginx\nserver {\n    listen 443 ssl;\n    server_name hub.example.com;\n\n    ssl_certificate     /etc/letsencrypt/live/hub.example.com/fullchain.pem;\n    ssl_certificate_key /etc/letsencrypt/live/hub.example.com/privkey.pem;\n\n    location / {\n        proxy_pass http://127.0.0.1:8800;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n\n        # WebSocket upgrade (relay + client mount)\n        proxy_http_version 1.1;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n        proxy_read_timeout 3600s;\n    }\n}\n```\n\nIf you allocate per-server subdomains, point a **wildcard** record (`*.hub.example.com`) at the\nHub and add a matching wildcard TLS certificate.\n\n\u003e **TLS for server subdomains:** automated ACME (Let's Encrypt) provisioning is **not** built in.\n\u003e Provision certificates out-of-band (e.g. a wildcard cert via certbot DNS-01) and point the Hub\n\u003e at them.\n\n## Docker\n\nA `Dockerfile` is provided (PHP 8.3 + Swoole/UV, nginx, supervisor).\n\n```bash\ndocker build -t phlix-hub .\n\ndocker run -d --name phlix-hub \\\n  -p 8800:8800 -p 8802:8802 -p 8803:8803 \\\n  -e HUB_DB_HOST=host.docker.internal \\\n  -e HUB_DB_USER=phlix_hub \\\n  -e HUB_DB_PASSWORD=CHANGE-ME \\\n  -e HUB_DB_NAME=phlix_hub \\\n  -e HUB_JWT_SECRET=\"$(openssl rand -hex 32)\" \\\n  phlix-hub\n\n# Apply migrations against the configured database\ndocker exec phlix-hub php /var/www/html/scripts/run-migrations.php\n```\n\nPoint `HUB_DB_HOST` at a reachable MySQL instance (a linked container, `host.docker.internal`, or\nan external host).\n\n## Configuration reference\n\nAll settings are environment variables, read in [`config/`](config). Defaults shown are the\ndevelopment fallbacks.\n\n### Server ([`config/server.php`](config/server.php))\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `HUB_HOST` | `0.0.0.0` | HTTP bind address |\n| `HUB_PORT` | `8800` | HTTP listen port |\n| `HUB_WORKERS` | `2` | Number of HTTP worker processes |\n| `HUB_WORKERMAN_LOG` | `.logs/workerman.log` | Workerman's own log file |\n| `HUB_PUBLIC_DOMAIN` | `phlix.media` | Base domain for per-server subdomains |\n\n### Database ([`config/database.php`](config/database.php))\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `HUB_DB_HOST` | `127.0.0.1` | MySQL host |\n| `HUB_DB_PORT` | `3306` | MySQL port |\n| `HUB_DB_USER` | `phlix_hub` | MySQL user |\n| `HUB_DB_PASSWORD` | `phlix_hub` | MySQL password |\n| `HUB_DB_NAME` | `phlix_hub` | MySQL database name |\n\n### Auth ([`config/auth.php`](config/auth.php))\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `HUB_JWT_SECRET` | _(random per-process)_ | **Required in production.** ≥32-byte HMAC secret |\n| `HUB_JWT_ACCESS_TTL` | `3600` | Access-token lifetime (seconds) |\n| `HUB_JWT_REFRESH_TTL` | `604800` | Refresh-token lifetime (seconds) |\n\n### Sonarr / Radarr (optional, for media requests — [`config/server.php`](config/server.php))\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `HUB_SONARR_URL` | `http://localhost:8989` | Sonarr base URL |\n| `HUB_SONARR_API_KEY` | _(empty)_ | Sonarr API key |\n| `HUB_SONARR_ENABLED` | `0` | Enable Sonarr fulfilment |\n| `HUB_RADARR_URL` | `http://localhost:7878` | Radarr base URL |\n| `HUB_RADARR_API_KEY` | _(empty)_ | Radarr API key |\n| `HUB_RADARR_ENABLED` | `0` | Enable Radarr fulfilment |\n\n## Database schema\n\nMigrations live in [`migrations/`](migrations) and are applied in filename order. The schema:\n\n| Table | Purpose |\n|-------|---------|\n| `users` | Hub accounts (Argon2id passwords; unique email + username) |\n| `servers` | Claimed media servers and their operational state |\n| `server_claims` | Pending/paired claim codes minted during pairing |\n| `server_heartbeats` | Recent heartbeats for liveness and clock-skew detection |\n| `relay_sessions` | One row per open WebSocket relay session |\n| `shared_libraries` | Library grants from a server owner to another user |\n| `library_shares` | Per-library shares with read-only / read-write levels |\n| `invite_links` | Single-use signed invite links |\n| `webhooks` | User-defined HTTP callbacks for `phlix.*` event aliases |\n| `media_requests` | Jellyseerr-class request queue |\n| `dns_challenges` | DNS-01 challenge records for subdomain TLS |\n| `hub_settings` | Hub-wide configuration key/value settings |\n| `federation_hubs` | Peer hubs for hub-to-hub federation |\n| `federation_library_shares` | Libraries shared across federated hubs |\n| `audit_logs` | Audit trail of administrative actions |\n\n## HTTP API\n\nSelected endpoints (full surface in [`src/Application.php`](src/Application.php)). Protected\nroutes require a `Bearer` access token (or session cookie for SSR pages).\n\n### Health \u0026 discovery\n\n| Method | Path | Notes |\n|--------|------|-------|\n| `GET` | `/health` | Service + version JSON |\n| `GET` | `/.well-known/jwks.json` | Public JWKS |\n\n### Auth\n\n| Method | Path | Notes |\n|--------|------|-------|\n| `POST` | `/api/v1/auth/register` | Create account (canonical; `/api/v1/auth/signup` is an alias) |\n| `POST` | `/api/v1/auth/login` | Obtain access + refresh tokens |\n| `POST` | `/api/v1/auth/refresh` | Exchange a refresh token |\n| `POST` | `/api/v1/auth/logout` | Invalidate session |\n| `GET` | `/api/v1/me`, `/api/v1/auth/me` | Current user, incl. `is_admin` (protected) |\n\n### Servers\n\n| Method | Path | Notes |\n|--------|------|-------|\n| `POST` | `/api/v1/server-claims/new` | Server mints a claim code |\n| `POST` | `/api/v1/server-claims/claim` | User redeems a claim code (protected) |\n| `GET` | `/api/v1/me/servers` | List your servers (protected) |\n| `DELETE` | `/api/v1/me/servers/{id}` | Remove a server (protected) |\n| `GET` | `/api/v1/me/servers/{id}/access-info` | Connection info (protected) |\n| `POST` | `/api/v1/servers/{id}/heartbeat` | Server liveness (enrollment JWT) |\n| `GET` | `/api/v1/servers/{id}/info` | Server metadata (enrollment JWT) |\n| `POST`/`DELETE` | `/servers/{id}/subdomain` | Allocate / revoke subdomain |\n\n### Sharing, invites \u0026 requests\n\n| Method | Path | Notes |\n|--------|------|-------|\n| `POST`/`GET` | `/api/v1/me/shares` | Create / list library shares |\n| `PATCH`/`DELETE` | `/api/v1/me/shares/{id}` | Update / delete a share |\n| `POST`/`GET` | `/api/v1/me/invite-links` | Create / list invite links |\n| `GET` | `/invite/{token}` | Accept an invite (public page) |\n| `POST`/`GET` | `/api/v1/me/requests` | Create / list media requests |\n| `GET` | `/api/v1/admin/requests` | Admin request queue |\n| `POST` | `/api/v1/admin/requests/{id}/approve` | Approve a request |\n| `POST` | `/api/v1/admin/requests/{id}/deny` | Deny a request |\n\n### Admin (web console)\n\nThe Vue admin console at `/app/admin/*` is backed by these admin-gated endpoints\n(`[AuthMiddleware, AdminMiddleware]` — 401 unauthenticated / 403 non-admin):\n\n| Method | Path | Notes |\n|--------|------|-------|\n| `GET` | `/api/v1/admin/dashboard/summary` | Server fleet (total/online/offline), active relay sessions, pending requests, user count |\n| `GET` | `/api/v1/admin/dashboard/activity` | Recent audit events (`?limit=`) |\n| `GET`/`POST` | `/api/v1/admin/users` | List / create accounts |\n| `GET`/`PUT`/`DELETE` | `/api/v1/admin/users/{id}` | Fetch / update / delete an account |\n| `POST` | `/api/v1/admin/users/{id}/set-admin` | Grant / revoke admin |\n| `POST` | `/api/v1/admin/users/{id}/reset-password` | Set a new password |\n| `GET` | `/api/v1/admin/logs`, `/logs/tail`, `/logs/tail-all` | Browse / tail the hub log files |\n| `GET`/`PUT` | `/api/v1/admin/settings` | Read / persist hub settings |\n\nThe same logic is also reachable under `/api/v1/me/*` for back-compat (`/me/audit-logs`,\n`/me/logs*`, `/me/hub-settings`, `/me/federation/*`).\n\n### Relay (WebSocket)\n\n| Endpoint | Port | Notes |\n|----------|------|-------|\n| Server tunnel | `8802` | Server opens its outbound tunnel here |\n| `GET /client/{server_id}` | `8803` | Client connects and is routed to its server |\n\n## Connecting a media server\n\n1. On the Hub, sign in and open **My Servers** (`/app/servers`) to start a claim — the legacy\n   `/claim-server` page still works too — or the server requests a code via\n   `POST /api/v1/server-claims/new`.\n2. Enter the claim code on the server; the server is issued an **enrollment JWT** and registers\n   its public key.\n3. The server opens its **outbound relay tunnel** to the Hub and begins sending heartbeats.\n4. The server now appears under **My Servers** (`/app/servers`), and remote clients can reach it\n   through the Hub — no inbound ports required.\n\n## Testing \u0026 quality\n\n```bash\ncomposer test     # PHPUnit (Unit + Integration suites)\ncomposer cs       # PHP_CodeSniffer (PSR-12)\ncomposer stan     # PHPStan (level 9)\ncomposer psalm    # Psalm (errorLevel 1)\n```\n\nCI runs on every push and pull request via [GitHub Actions](.github/workflows/ci.yml):\n\n- Composer validation\n- PHP_CodeSniffer (PSR-12)\n- PHPStan (level 9)\n- Psalm (errorLevel 1)\n- PHPUnit (with coverage uploaded to Codecov)\n- Composer security audit\n\n## Project structure\n\n```\nphlix-hub/\n├── config/          # Environment-driven config (server, database, auth, logger)\n├── migrations/      # Idempotent SQL migrations\n├── public/\n│   └── index.php    # Workerman HTTP entry point\n├── scripts/\n│   └── run-migrations.php\n├── src/\n│   ├── Application.php   # Worker bootstrap + route registration\n│   ├── Auth/            # JWT, users, auth manager\n│   ├── Common/          # Container, database pool, logging, web portal\n│   ├── Http/            # Router, request/response, controllers, middleware\n│   ├── Hub/             # Claims, heartbeats, sharing, DNS, TLS, relay sessions\n│   ├── Federation/      # Hub-to-hub federation peers, shares, sessions\n│   ├── Relay/           # Reverse-tunnel relay workers, frame codec, tunnels\n│   └── Requests/        # Media request manager\n├── tests/           # PHPUnit Unit + Integration suites\n├── web-ui/          # Vite + TypeScript SPA (@phlix/hub-web-ui), built to public/assets/app/\n└── Dockerfile\n```\n\n## Related repositories\n\n- [`detain/phlix`](https://github.com/detain/phlix) (a.k.a. `phlix-server`) — the local media server.\n- [`detain/phlix-shared`](https://github.com/detain/phlix-shared) — shared interfaces, DTOs, and protocol types.\n\n## License\n\nMIT — see [`LICENSE`](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdetain%2Fphlix-hub","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdetain%2Fphlix-hub","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdetain%2Fphlix-hub/lists"}