CommandBus queues typed, panel-signed (`cmd`) commands per node in
cluster_commands: FIFO by seq, dedupe_key for desired state, expiry,
pruned by cron:cluster. New ops: `commands` (long-poll after the node's
high-water, held up to 20 s; frozen while quarantined) and `ack` (own
commands only; raises cluster_nodes.cmd_seq).
For nodes with the COMMANDS flow on (toggled on Cluster Nodes),
ClusterRoute sends NodeRpc::request() as a signed node.rpc and waits for
its ack, and NodeRpc::broadcast() / SignalDispatcher::kill() queue
commands; other nodes and every LB keep the legacy transport.
On the node, `console.php cluster:exec` checks a command again against
the panel key the agent pinned and runs node.rpc through the legacy /api
handlers (InternalApiController::runCommand, NodeRpc::ACTIONS only) or
conn.kill_worker. Root actions stay on the signals table for now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
When MAIN's broadcast port changes and the API has no port of its own,
ClusterEndpoint::recordChange() runs before the ports are applied: it
bumps cluster_policy_ver (agents refetch the policy within a heartbeat)
and keeps the old port for 7 days (migration 038). The policy lists it
after the new URLs; MAIN's nginx gets bin/nginx/conf/cluster_legacy.conf
from the set_port handler, one server block per kept port serving
/cluster/v1/ and 404 for everything else (included by glob). cron:cluster
drops expired ports, bumps the policy and re-applies MAIN's ports.
nginx -t passes with and without the file; a run confirmed only
/cluster/v1/ reaches PHP on the old port.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
LivenessService::tick() runs every second in MAIN's signals daemon
(cron:cluster is the minute fallback) and judges the active nodes whose
TELEMETRY flow is on by their silence: ok, suspect after 10 s, offline
after cluster_offline_after_sec (30 s). The result, in
tmp/cluster/health.json, is read through Core\Cluster\ClusterHealth:
ServerRepository::getAll() takes server_online from it for those nodes
(instead of the 90 s last_check_ago rule) and getCapacity() doubles a
suspect node's weight. Each transition rewrites the servers cache and is
audited.
When over half of the judged nodes (at least two) fall silent together,
the fleet silence guard holds every node at its last state instead of
marking any offline, audits it and alerts on the Cluster Nodes page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
For a node with the TELEMETRY flow on, MAIN writes the server's
watchdog_data, last_check_ago, requests_per_second, php_pids (and,
without the Redis handler, connections/users) from the agent's heartbeat
every 5 s, and its servers_stats row every minute.
HeartbeatService::toWatchdogData keeps the legacy getStats() shape,
pinned by ClusterTelemetryTest against getStats()'s source.
On the LB, Core\Cluster\NodeFlows reads the mode and flows the agent
writes to config/cluster/flows.json. With TELEMETRY on, the watchdog
stops writing its row and only refreshes local.json (nginx rps, fanout
status) for the agent; cron:servers skips its stats row and network.py
is stopped. The Cluster Nodes page toggles the flow per node.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
Servers → Cluster Nodes (permission `servers`) is the admin side of the
cluster CLI, via Domain\Cluster\ClusterAdmin: enrolled nodes with state,
liveness, epoch, token expiry, last heartbeat and agent version; code
enrolments waiting for a SAS (approve / reject); issuing an enrolment
code (shown once); and revocation. The deciding admin is recorded.
Strings in all eight languages.
cron:cluster (enabled by migration 037) deletes expired epochs (erasing
their z), the replay cache and used challenges, unused expired codes and
decided requests after a day. It returns at once on load balancers and
while the API is disabled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
For an LB MAIN cannot reach over SSH. `cluster:enrol-code <id>` issues a
one-time code (MAIN URL, panel key hash, 128-bit secret; 30 min). MAIN
keeps only K_req/K_res, sealed to the machine under a row MAC.
The node sends enrol_code (sid:<n>, epoch 0; MAC'd with K_req, signed by
the key it asks to enrol, body SEALed to the panel box key). Nothing is
minted until `cluster:enrol-approve <id> <SAS>`: the admin types the SAS
the node shows, which binds its keys; five wrong entries reject. The
node then collects epoch 1 via enrol_code_status (MAC'd with K_res,
panel-signed `pre`) and finishes with enrol_complete.
A wrong code MAC records nothing; other keys under a used code are a
signed ENROL_CONFLICT, the same keys again are accepted. Migration 036
keeps the request's per-epoch key until approval. Both commands are
MAIN-only (stripped from the LB archive).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
An active node with intact keys gets a new epoch without SSH:
GET challenge?cn=, then a node-signed POST token_rekey (epoch 0, no MAC)
whose body is SEALed to the panel box key under the request context.
MAIN checks the signature before recording anything, then the nonce,
state=active, a once-a-minute limit, the single-use challenge
(NonceStore::consume, atomic via a used marker), the instance_id
attestation (a mismatch quarantines) and the licence (the mint). The
reply is panel-signed `pre` and bound to the node and request nonce.
TokenService::rekey drops every other epoch row.
The install data now carries panel_box_pub; the test fake gets a real
box key, openSealed and licence-gated granting signatures.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
- `console.php xcvm_core status` reports the loaded extension and its
cluster API: the version against the range the panel speaks, whether
the root is initialised (with its fingerprint), the licence binding kid
and the clock state.
- The installer rolls back an update whose cluster API falls outside
ClusterCryptoFactory::API_MIN..API_MAX when the installed one was
inside it, so an update cannot silently strand enrolled nodes.
Installing an exact pinned version still needs versioned paths in the
binaries repo.
- ClusterExtensionIntegrationTest runs the panel facade against a real
test-hooks build, and skips without one or without a throwaway
XCVM_CONFIG_DIR. It covers init, token issue, the agent opening its
token, MAIN's session, BOX both ways, per-node binding of epoch records,
and signing with record classes. It passes against the 2.2.2 build.
- ADR 0004 records the panel's side of the contract:
- who owns which part of the wire;
- the canonical request/response form;
- fail-closed rules;
- migration renumbering (the plan's 026–032 ship as 028–034);
- where the settings live;
- the measured crypto budget.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
The last two Phase 0 seams:
- Core\Cluster\NodeRpc wraps every ApiClient::systemRequest() and
asyncRequest() call. It covers 37 call sites across the admin
controllers, the admin API wrapper, ServerRepository/ServerService,
StreamService, DiagnosticsService and AuthRepository. The HTTP transport
is unchanged. In Phase 4 these become node.rpc{action} commands.
- Core\Cluster\NodeActions wraps every SignalDispatcher::rootAction() call
(26 sites) with typed methods: reboot, restartServices, update,
rollback, updateBinaries, setRamdisk, setPorts, setServices,
setGovernor, setSysctl, flushBlocklist, certbot. send() takes payloads
the caller builds (modules, OPENSSL_EXTRA). The queued JSON is
byte-for-byte what the callers wrote. In Phase 4 these become
node.root{action} commands.
Each seam refuses an action outside its catalogue (NodeRpc::ACTIONS,
NodeActions::ROOT_ACTIONS). NodeRpcActionsTest checks that:
- every call site uses a catalogued RPC action;
- RootSignalsCronJob handles every catalogued root action;
- nothing outside Core\Cluster calls the raw functions.
docs/en/development/cluster-seams.md lists all the Phase 0 seams, their
legacy backends and the rules for new code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
With fanout switched off (FanoutMode::legacyDelivery(), which is also true
when the licence denies fanout), live delivery goes back to the paths that
ADR 0003 Phase E removed. Each is restored from git history:
- HLS (E3): live.php serves HLSGenerator::generateHLS() over the on-disk
playlist. segment.php serves on-disk LIVE segments again through
X-Accel /xc_hls/, AES-128-encrypting on first read, and burns a pending
send-message signal with SignalSender. generateHLS now mints tokens with
Encryption::mintToken (so secure_stream_tokens applies), rewrites one
segment line at a time (a str_replace of 1_1.ts also hit 11_1.ts), and
tokenizes an fMP4 init map.
- TS (E2): live.php chase-reads the on-disk segments again (SegmentReader
and its test restored). $rSegmentSettings, which the old loop read but
never defined, is now set there. A signalled segment is no longer sent
twice.
- Proxy streams (E1): StreamProcess::startProxy() starts ProxyCommand
(XC_VMProxy[<id>]), and live.php relays its datagrams from the viewer's
unix socket. Fixed while restoring:
- the read loop ended after its first read (a stray break);
- it spun on an idle source;
- it never exited when the last viewer left;
- it passed the proxy argument's whole row to ffmpeg;
- it ran ffmpeg with no input when no source opened.
Runtime state goes through StreamStateWriter.
With fanout on, nothing changes: a daemon that is merely down still gives
not-on-air until the keepalive restarts it, and segment.php still 404s
non-daemon live segments.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
settings.fanout_enabled (migration 027, default on) is a new "Fanout
Delivery" toggle beside the other fanout settings. Streaming\Fanout\FanoutMode
reads it and applies it to a node:
- Off: FanoutMode::applyToNode() writes bin/xc_fanout/disabled, then stops
run.sh (so it cannot respawn the daemon) and then xc_fanout. It runs on
MAIN when the settings are saved, and on every node from RootSignalsCronJob
within a minute. The RootSignals keepalive and the hourly fanout_binary
self-heal skip the daemon while the flag is set; `service boot` and run.sh,
which cannot read settings, check the flag file.
- Every daemon-facing gate honours the switch. LicenseGate::fanoutUsable()
and each FanoutClient control-socket call behave as if the daemon were
down. IngestFeeder::forStream() returns a no-op feeder, and DelayCommand
stops queueing segments for it. fanout_sync stops writing the daemon
config and closes the pid-0 connection rows the stopped daemon left.
- The dashboard no longer reports "xc_fanout down" when it was switched off.
The pre-fanout delivery paths for fanout off follow in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
server:install received the root username and password as positional
arguments, visible in ps and /proc/<pid>/cmdline for the whole install, and
LbInstallFlow/ProxyInstallFlow saved them in a plaintext
bin/install/<id>.json for reinstalls. Any SSH host key was accepted.
- The panel writes a 0600 bin/install/<id>.cred and runs
`server:install <type> <id> <port> - - … --cred-file=<path>`; the command
reads and deletes the file before connecting (InstallCredentials). Only
<id>.cred files in bin/install/ are accepted.
- <id>.json keeps the non-secret parameters only; reinstall_server now needs
root_password in the request. cron:cleanup (MAIN) scrubs root_password from
files written by earlier releases.
- The node's SSH host key (SHA-1) must match the optional Expected SSH Host
Key (hex or `ssh-keygen -l -E sha1` output), else the key stored by the
first install (new servers.ssh_hostkey_sha1, migration 026), else it is
trusted on first use and stored.
- New install-form field and strings in all eight languages; cli-tools docs.
Phase 0 stop-gap of the MAIN <-> LB API plan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
The LB strip lists still named files by their pre-PSR-4 www/ paths, so
those entries matched nothing and the files kept shipping under Public/:
Public/admin/api.php, Public/admin/proxy_api.php, Public/stream/auth.php
and Public/stream/probe.php reached every LB. auth.php and probe.php are
routed by the LB nginx but fatal there on the stripped Domain/User.
LB_DIRS still listed the removed resources/ and www/ trees, and
LB_DIRS_TO_REMOVE listed the non-existent Domain/Auth. The gate never
noticed, because it only checked the SENSITIVE prefixes.
Makefile:
- Drop the dead entries (resources, www, Domain/Auth, GeoLite2-City.mmdb,
the nine www/* files).
- Strip the MAIN-only files that no LB route or LB code needs: the four
endpoints above, PlayerV2, AdminAPIWrapper, ResellerAPIWrapper,
ActiveCodeApiController, ResellerApiDispatcher, ResellerTableRenderer,
Cli/migration_logic.php and DbMigrateCommand (on an LB it would run
MAIN's schema migrations). The viewer-API controllers still ship
because the LB nginx still routes them; they go later with the routes.
- lb_delete_files_list now always writes the LB deleted_files.txt as the
LB-scoped git deletions plus every stripped code file (Cli, Core,
Domain, Infrastructure, Public, Streaming; never bin/config/content).
An update only deletes listed files, so without this the files newly
stripped here would stay on LBs that are already installed. The
retired resources/ and www/ trees stay in the git-deletion scope
(LB_RETIRED_DIRS), so their old entries are still applied.
lb_configs/nginx.conf: stop routing /stream/auth, /stream/probe and
/probe.php. They now get nginx's 404 instead of a PHP fatal. MAIN nginx
is unchanged.
verify-lb-archive.sh now also fails when:
- an LB_* entry matches no tracked path (STALE);
- a script the LB nginx executes is stripped (MISSING): every
SCRIPT_FILENAME, and Public/<scope>/<handler>.php for each handler the
/stream/ and /admin/ gateway locations accept.
SENSITIVE covers the new strips, CacheHandlerCommand and
CacheEngineCronJob. The manifest lookups now use here-strings, because
under pipefail an early `grep -q` exit could SIGPIPE the writer and hide
a leak. LbArchiveGateTest runs the real script with each failure mode
injected through MAKEFLAGS.
Behaviour: the LB archive loses 26 files that no LB route reaches (or
that fatal there today), and the next LB update deletes them. It also
deletes any lingering Domain/User or Domain/Device files. Fresh LBs
never had those, so an older LB that still carries them loses rtmp and
viewer-API paths that only worked because of them, just like a fresh LB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
server:sync-openssl-extra now refuses to send when the main's own
published fingerprint differs from the value it read. Run as root, it
could read a config/openssl_extra that xc_vm cannot read, while the
main's php-fpm runs on the default, and push a value that breaks every
LB. The command and cron:root_signals now share one contract:
OpensslExtra::SIGNAL_ACTION, signal() and applySignal(), with
publish() for cron:servers.
Encryption::readToken now tries the sealed format with both values
before the legacy one. A wrong-value legacy decrypt() could return
garbage (about 1 in 256), so a sealed token minted with the replaced
value was refused. Legacy tokens keep that inherent miss rate.
proxy_api trusts the posted server_id and handed any registered proxy
an LB's signal rows, including the main's OPENSSL_EXTRA. It now skips
set_openssl_extra rows until the source-IP binding lands.
Tests cover sync() with TestDb, the signal contract and the case's own
break. The docs cover the check and the repair.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
Condensed plan (revision 3 + 28 consistency corrections): LB nodes stop
reaching MAIN's MySQL and Redis and move every exchange to a signed,
encrypted cluster API with licence-derived, rotating node tokens.
This is the source design for the task-level implementation plan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
Resellers' sidebar is currently a hardcoded PHP array in
layouts/reseller/header.php with no module extension point, unlike
admin's NavbarRegistry/NavbarItem/CoreNavbarProvider. Design a sibling
ResellerNavbarRegistry + ResellerNavbarProviderInterface (mirroring the
existing PermissionRegistry precedent for reseller-specific module
extensions) so modules can register reseller sidebar entries the same
way they already do for admin.
A key confirmed by the licence server once keeps working offline with no time
limit; the panel only has to reach the server once within 14 days of the key
being issued. Reword the offline section, the expiry note and the closed-network
FAQ accordingly.
Rewrite the ru page as a mirror of en: it still described the old model (a
community install needs no key and never contacts the server, activation only
for white-label, free keys).
Replace the dual-licensing (attribution OR key) description with the
current model: attribution and a per-installation activation key are
both required, verified daily with a 14-day grace period, and gate
multi-server (LB) operation. Adds the paid-module licensing and an FAQ.
Adds docs/en/info/licensing-and-activation.md (dual-licensing, AGPL §7b
attribution check, what activation gates, how to activate, what the panel
sends to the licence server and what it never sends, provenance), wires it
into the mkdocs nav (+ ru label), and extends the README "License
Enforcement" section with the activation + data-transparency disclosure.
Makes the activation / attribution / phone-home mechanism formally disclosed
(the legal basis for enforcement on top of AGPL). English source only; ru is
generated.
functions.php and session.php were removed. Repoint the admin session
validation, lifecycle and file-reference sections at
AdminScopeBootstrap::hydrateAdminContext(), the shared
SessionManager::adminSessionValid() integrity guard, and SessionController
(the AJAX session-poll route). docs/ru is regenerated at release.
The prelude shims (Paths/AppConfig/Binaries/ErrorCodes.php) were deleted and the
$rErrorCodes global is gone. Point the developer guides at the new homes:
constants → ConstantsInitializer (paths()/appConfig()/binaries() maps), error
catalogue → ErrorResponder::codes(). Also refresh the now-outdated "refactored
later" note in build/rector.php's skip list.
Only docs/en is edited (docs/ru is regenerated from it before a release);
make docs-build passes.
XC_VM_VERSION / DEV_MODE / DB_ACCESS_ENABLED / DB_ACCESS_PWD are edited on every
release (and by the release automation). Buried as array entries in appConfig()
they were awkward to find and to sed. Move them back to guarded define()s at the
top of ConstantsInitializer.php; appConfig() reads them back, and init() skips
the already-defined ones. The guard keeps a pre-definition (e.g. the PHPStan
stub) from fataling.
Update the release checklist accordingly: the sed commands target the familiar
`define('XC_VM_VERSION', '...')` form in ConstantsInitializer.php again (they were
pointing at the deleted AppConfig.php).
The Playwright suite only checked that pages render. It now performs the
administrator's work against a live test panel and asserts the panel's own
data after each step:
- catalogue: a stream category, a bouquet and a reseller package — created,
renamed / edited, reopened, deleted;
- subscribers: a line with a bouquet (search, edit in the modal, disable /
enable, ban / unban, delete), a MAG and an Enigma2 device;
- bulk: two lines selected with the header checkbox, disabled and deleted;
- resellers: created with credits, topped up, edited, disabled, deleted;
- block lists: an IP (RFC 5737 address — blocking adds an iptables rule), a
user agent and an ISP;
- streams: a live stream added with a source and a server, started, running
with codecs and the Resources column filled in, stopped, renamed, deleted;
- sign-in: a second administrator refused with a wrong password, signing in
and out — a separate account, because every admin login re-hashes the
password and ends that account's other sessions.
Records are named `e2e-<run>-…`; a teardown project sweeps whatever a run
leaves behind and nothing else. tools/create-admin.php provisions the
dedicated test administrator on the panel host.
The first runs found three save paths that answered an empty page (fixed in
f7bba5cb, fd13c940, ee2f586a). Against the test panel: 82 passed, 1 skipped
(no series to select).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WDhDajBPziJwjWZcXnh6R
Add the Category Templates management UI on top of the already-committed
CategoryTemplateService:
- Admin CategoryTemplate(s)Controller + Ajax controller (create/save/delete/
clone/toggle-system/apply-all/get); reseller CategoryTemplate(s)Controller
and views.
- Apply a line's custom_data template layout when building output:
CategoryService (per-category sync), LineService, MagService,
PlaylistGenerator, and CacheEngineCronJob (custom_data in the line SELECT).
- Register admin + reseller routes and navbar entries; these shared files
also carry the Active Codes routes/navbar wiring.
Admin Category Templates views are not part of the re-sent changeset, so the
admin CT pages render empty until those views are added; reseller CT is
complete.
Co-Authored-By: AbdoAhmedElbanaa <115952879+AbdoAhmedElbanaa@users.noreply.github.com>
The committed PHPUnit runner lived at tools/.bin/phpunit.phar, away from
the suite it runs. Move it next to the tests it drives —
tests/phpunit.phar — and update every invocation to
`php tests/phpunit.phar -c tests/phpunit.xml.dist`:
- CI workflows (ci, build-release, build_pre-release) + the ci.yml header,
- CLAUDE.md, CONTRIBUTING.md, tools/README.md, the qa-lead-reviewer agent,
- docs/en (dev-workflow, updates_checklist, phpunit-phar, refactoring).
docs/ru is generated from docs/en (make docs-translate) and is left for
the next regeneration, per the docs workflow.
Adopt Rector as a require-dev tool alongside PHPStan/phpcs for safe, mechanical
refactoring:
- src/composer.json: add rector/rector ^2.0 (require-dev) + refactor/refactor:dry
composer scripts.
- build/rector.php: narrowly-scoped config over the PSR-4 class trees
(Core/Domain/Cli/Infrastructure). Skips the \TMDB lib, the streaming hot-path,
vendor, tmp/backups. Import-adding stays OFF (the check-procedural-use gate
relies on positional use imports). Behaviour-changing rules are disabled
(SafeDeclareStrictTypes, UseIdenticalOverEqualWithSameType); only the safe
deadCode + codeQuality prepared sets run (incl. the empty-if/else collapse).
- Makefile: `make rector` (dry-run, non-zero on pending changes) and
`make rector-fix` (apply). Both require dev-tools.
- docs/en/guides/refactoring.md + dev-workflow.md + mkdocs.yml nav: the
detect -> diff -> verify -> apply workflow.
No source code is changed by this commit — scaffolding only. `make rector-fix`
output will be reviewed separately.
Stream-link tokens were AES-CBC with a fixed IV and no MAC. A modified token
decrypts to modified bytes, and a padding error answers differently from a bad
credential (auth.php: BAD_TOKEN vs everything after), so with enough requests
anyone holding a link could read its username and password, or write a token of
their own. Several consumers trust a token's contents as they stand: the live /
vod / timeshift JSON (user_info, channel_info), HLS segment and key tokens, the
web player's proxy URL (fetched server-side) and the MAG portal's verify token
(passed to igbinary_unserialize).
Encryption::seal()/open() add AES-256-GCM with a random nonce, as
base64url(nonce ‖ ciphertext ‖ tag) — the same URL-safe alphabet, so no nginx
route or pattern changes. Every stream-link token is now made with
mintToken() and read with readToken(); StreamTokenCallSitesTest keeps new code
from calling the legacy encrypt()/decrypt() for one. Deterministic encryption
of stored data (HMAC keys looked up by ciphertext, image cache names) stays as
it was.
The new setting secure_stream_tokens (Settings → Tamper-proof Stream Tokens):
- on: tokens are sealed, and the legacy format is refused wherever a token's
contents are trusted. /play/ playlist and portal links, RTMP tokens and
probe's /play/ links still read the old format — they carry credentials that
are looked up again, and saved playlists hold them — and every token auth.php
cannot read now counts against the address (BruteforceGuard), which stops
reading an old one through the error responses.
- off: legacy tokens are minted and every format is read.
Servers on an older version cannot read sealed tokens, so migration 021 turns it
off on a panel that has other servers (on for a single server, and for new
installs); turn it on once every server is updated.
key.php now also refuses a token that does not read, instead of serving the key
of stream 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbYsGKhirq9eRK8e6wsCHR
The admin and reseller login pages block an address after login_flood failed
sign-ins in 24 hours. They counted them with
TIME_TO_SEC(TIMEDIFF(NOW(), `date`)) <= 86400, but login_logs.date is an
int(11) Unix timestamp: TIMEDIFF of a DATETIME and an integer is NULL, so
no row ever counted and no address was ever blocked. Checked on MariaDB
11.4: with two failures in the last day the old query counts 0, the new one
2. And failures were only written when "save login logs" was on, so even a
working count would have seen nothing with logs off.
Both pages now ask Authenticator::loginFloodExceeded($ip, $limit), which
compares `date` with time() - 86400, and failed sign-ins (INVALID_LOGIN) are
always recorded — they are the limit's memory; save_login_logs still governs
every other outcome. The auth guide documents the limit, and catches up with
the session-id renewal and cookie flags from 010cdb1b.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNXXkamPAhGah3U3SwHn8z
CPU is a difference between two /proc readings, and the previous one was kept
in the stream's progress_info row — so a figure depended on that value
surviving a round trip through a row other code also rewrites, and the first
pass of every producer showed a dash for a minute.
The previous reading now lives beside the stream's files, in
<streams>/<id>_.usage (tmpfs, removed with the rest of <id>_* when the stream
stops): node-local bookkeeping, like the pid file. Where there is no usable
previous reading — a producer's first pass, or a new pid after a restart — the
lifetime average stands in, as ps reports it, computed from the process's own
start time in /proc/PID/stat against /proc/uptime rather than /proc/PID's
mtime, which is only set when something first looks at the directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WwKmPG4RPK4cnJRAxQhPdL
The daemon reads a stream's codecs and picture size off the bytes it fans out
and reconcileSupervised copied them into `video_codec`, `audio_codec`,
`resolution` and `bitrate` — but not into the `stream_info` JSON, which is the
shape the rest of the panel actually reads. A supervised stream therefore
showed "? x ?" and "N/A" in the streams list; worse, every adaptive variant was
dropped from the master playlist for want of a width, and stream/auth.php fell
back to calling every stream h264 when handing the viewer its codec.
The JSON is now written beside the columns, merged rather than replaced, so
whatever ffprobe once found that the daemon does not read (frame rate,
container) survives, and an unchanged reading writes nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZP7fc2Hz36wbPmuLd9F9o
A new "Resources" column between Stream Info and Actions: which process is
producing the channel (the fanout daemon's native remuxer, ffmpeg, or PHP for
the LLOD segmenter / loopback relay), the CPU it is burning and the memory it
holds. With the native remuxer now an option per stream, "what does this
channel actually cost" and "which backend is it on" are the two questions the
list could not answer.
ProcessManager reads both from /proc/PID/stat (fields 14/15 and 24, with the
page size derived rather than assumed — 64K pages are normal on arm64). CPU
there is cumulative, so a percentage needs two readings: cron:streams samples
each producer once a pass and folds cpu/mem/producer into the stream's
progress_info, carrying the previous reading in the same JSON to subtract from.
cpuPercent() returns null rather than a wild figure when the pair says nothing
— no previous sample, same instant, or a counter that went backwards because
the producer restarted. Only the node running a stream can read its own /proc,
so the sampling happens there and reaches the panel in the row the cron already
writes; MAIN just renders it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZP7fc2Hz36wbPmuLd9F9o
Two reasons the native remuxer never ran on a real panel, both in the
eligibility check:
- it compared `type_key` against `live_streams`, which is no type at all —
`streams_types` holds (1, 'Live Streams', 'live'), (3, 'created_live'),
(4, 'radio_streams'). Every ordinary live channel was refused, so
`fanout_source_backend` native/auto silently kept running ffmpeg. The new
log line said it out loud ("ffmpeg runs this stream: not a live channel"),
which is how it surfaced; the refusal now names the type it saw.
- `gen_timestamps` and `read_native` were treated as "the operator asked for
timestamp repair / realtime pacing", but both DEFAULT to 1 in `streams`, so
they carry no intent and refusing them refuses everything. -re paces a
file-ish input, which a passthrough of a live source does by itself, and
genpts only synthesises timestamps a source failed to send — a source that
broken has no usable video clock either, which ends the run with exit 3 and,
in `auto`, hands it to ffmpeg.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K48c64npichw9ZCZDU16ja
Three things an operator could not see, all in the file they already open:
- the command handed to the supervisor is recorded beside the stream's files
the way the self-launched path records its ffmpeg line — <id>_.fanout for the
native remuxer, <id>_.ffmpeg for ffmpeg (in auto, both: the second is the
fallback). A supervised stream used to leave no record at all.
- when the native backend is on and a stream runs ffmpeg anyway, the reason is
appended to <id>.errors ("[panel] ffmpeg runs this stream: Generate PTS is
on"). isNativeEligible() becomes nativeRefusal(), returning that sentence
instead of a bare false, because the answer is always one of these settings.
- the panel only composes `xc_fanout remux` when the node's daemon advertises
it (features in GET /monitors/state, FanoutClient::supportsRemux). An older
binary does not reject that command, it misparses it — "remux" reads as a
positional argument, the process tries to become a second daemon on sockets
the running one holds, and the stream never starts. On a node whose panel was
updated first, streams now keep running ffmpeg and say so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K48c64npichw9ZCZDU16ja
How live streams are handed to the xc_fanout supervisor, when a copy-only
stream runs `xc_fanout remux` instead of ffmpeg, how streams_servers is
kept in step, and what still runs under the PHP monitor. Also the
remuxer case of ProcessManager::isStreamRunning(), StreamProcess::
isWatched(), the monitor command's stand-down, and the two settings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012QL93N6dzGkmKoQgA4oh16
- module-extension-points: Topbar, Table, Permission and Quick Tools provider
sections + the rule that a module owns its table end-to-end (clean JSON,
module api routes, events — core never touches module tables).
- module-authoring: list the optional providers in the sub-contract tree and
the method table.
- core-wiring: bootAll steps 6–9 (topbar/table/permission/quick-tools) and that
registries run without a router.
- http-request-handling: REST path boots modules (registries only) and serves
module tables generically via TableRegistry.
Consolidate the two stream-check tools into a single master script with
three subcommands:
- check <url> verify one stream (or --live dashboard)
- playlist <path|url> batch an .m3u list -> aggregate JSON (+ per-stream files)
- graph <inputs...> render the JSON as SVG charts
Shared helpers (HTTP, slugify, m3u/JSON handling) are now defined once.
The old --playlist flag becomes the `playlist` subcommand and the bare-URL
form becomes `check <url>`; the streamtest harness is updated accordingly.
Also fix the per-stream SVG rendering black in viewers that do not support
8-digit #rrggbbaa hex: the bitrate area fill and not-PLAYING bands now use
6-digit hex plus a separate fill-opacity attribute.
Docs (English + tools READMEs + STREAMTEST) updated to the new invocation.
Removes stream_queue_check.py and stream_graph.py.
src/config/permissions.php was never loaded anywhere and was internally
broken (built its rows through an undefined $language). Its 100-key list and
[key, title, text] row-building are duplicated verbatim by
XcVm\Core\Reference\PermissionReference (keys()/advanced()), the live source
the group editor (Views/admin/group.php) already uses. Its returned
$rPermissions was unrelated to the runtime global $rPermissions, which comes
from the users_groups table via AuthRepository::getPermissions().
Update the RBAC guide to point at PermissionReference (docs/en; the ru tree is
regenerated before release).