Once the signals daemon had run its first settings check, it called
ServerRepository::getAll(true) - a SELECT * FROM servers against MAIN - on
every pass of a loop that sleeps 250 ms: up to 240 reads a minute from every
node. The watchdog and the cache handler tied the reload to the settings
interval instead (10-60 s), so their view of the cluster could be a minute
stale.
DaemonTrait::serversRefreshDue()/refreshServers() reload at most every five
seconds, and the three daemons use it: 12 reads a minute, never more than
five seconds stale (SignalsLoopQueryRateTest). Phase 0 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 cluster API plan switches a node off the legacy link only after seven
days with no connect to MAIN's MySQL or Redis, counted at
Database::db_connect() and RedisManager::connect(). Both now call
ConnectAudit::record(), which appends {t, kind, call site, pid} to
STORAGE_PATH/cluster/sql_audit/YYYYMMDD.ndjson (persistent, unlike tmp/)
and summary() turns into per-site counts for the heartbeat later.
Trace only and off by default: NodeRole::auditConnects() is on when
sql_audit/enabled exists (the cluster API sets it for hybrid nodes) or
under XCVM_CONNECT_AUDIT=1, checked once per process; nothing is refused.
cron:cleanup prunes day files older than eight days. Phase 0 seam.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
47 call sites wrote `signals` rows directly, in three shapes: a viewer
kill (pid, rtmp), a root action for cron:root_signals (custom_data.action)
and a cache job (cache = 1, custom_data.type). They now name the intent -
SignalDispatcher::kill / rootAction / cache / cacheBatch - and the
dispatcher writes through a SignalSink. The only sink is LegacySqlSignalSink,
which writes the same rows; the cluster API plan (Phase 4) adds the sink
that turns them into signed commands for API-mode nodes. Phase 0 seam.
Behaviour kept column for column (SignalDispatcherParityTest), with two
deliberate differences:
- Actions the panel wrote as literal JSON with a space ('{"action":
"update_binaries"}', restart_services, reboot) are json_encode()d like
every other one. StatusCommand deletes update_binaries rows by their
json_encode() form, so the panel-queued ones were never cleaned up.
- kill() writes nothing for a pid <= 0: the signals daemon skipped those
kills, but for RTMP it sent a drop for client 0.
The "insert unless an identical cache signal is pending" check that
LineService and StreamProcess repeated is cache(..., once: true).
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
proxy_api.php trusted the posted server_id. Any proxy could report stats
for any server, collect that server's signals (which the proxy runs as
root), and write its servers.whitelist_ips, which feeds allowed_ips, the
source allowlist of every LB's /api.
The server is now the proxy whose server_ip or private_ip is the request's
source address (ProxyIdentity::resolve); a posted server_id naming another
server gets the 404. Posted addresses are kept as telemetry
(watchdog_data.ips_seen) and no longer written to whitelist_ips, which stays
admin-maintained. Phase 0 stop-gap of the MAIN <-> LB API plan, until
proxies move onto the token scheme (D8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
Cron locks were CRONS_TMP_PATH . md5(generateUniqueCode(live_streaming_pass)
. class), so rotating the viewer-token secret (the cluster plan rotates it
at cutover and again at lockdown) renamed every lock and let a second
instance of each cron start beside one still running.
ProcessManager::cronLockPath() names the lock from the class alone; CronTrait,
RootSignalsCronJob and server:diagnose use it. For the upgrade itself,
exitIfCronLockHeld() honours a live, fresh lock under the previous name, so
an instance started by the old release is not doubled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
The crontab is copied verbatim to every load balancer, so each LB ran the
same cluster-wide work against MAIN's database: the retention DELETEs on
lines_activity/lines_logs/login_logs/streams_errors/streams_logs/
ondemand_check, the TMDb and TMDb-popular crawls, the purge of every node's
signals, the GitHub update check that rewrites settings.update_data, and
the marketplace check for module updates (modules are MAIN-only).
Add XcVm\Core\Cluster\NodeRole::isMain() (Phase 0 of the MAIN <-> LB API
plan; the node mode and flow bits land there later) and gate each of those
on it. An unknown answer reads as "not MAIN", so a node that cannot tell
skips one run instead of doing cluster-wide work. Per-node work (local
stream and archive cleanup, this node's certbot, this node's own signals)
is unchanged.
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
Load balancers added before provisioning shipped config/openssl_extra
run on the built-in OPENSSL_EXTRA while their main holds a random one,
so they reject every token the main mints for them. server:diagnose now
shows this; nothing could fix it short of a manual copy.
server:sync-openssl-extra <server_id>|--all [--force], run on the main,
queues one root signal carrying the main's value for each streaming LB
whose published fingerprint differs. Nodes that publish none (not
updated), offline nodes and nodes already in sync need --force; proxies
and the main are never sent it. A main without config/openssl_extra
runs on the default like its LBs, and the command sends nothing.
The LB's cron:root_signals handles 'set_openssl_extra' in its own case
(ignored on the main, whose value keys hmac_keys and image names) with
OpensslExtra::install: it writes the value it replaces to
openssl_extra.prev, accepted for 600 s by Encryption::readToken, then
swaps openssl_extra in through a 0600 temporary file owned like
config/, so php-fpm never sees a file it cannot read. php-fpm picks the
new value up on its next request. Installing the value already in use
keeps an earlier .prev. server:install now also clears a stale .prev,
and server:diagnose names the command in its mismatch report.
The value sits in the signals table until the LB's root cron deletes the
row, about a minute; a legacy LB reads that database in full anyway.
The command is MAIN-only: it is stripped from the LB archive and listed
in verify-lb-archive.sh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
A load balancer that runs on another OPENSSL_EXTRA than its main has to
be moved onto the main's value. Every stream link the node minted with
its old value (HLS segment and key tokens, VOD links a player reuses to
seek, tokens from direct-to-LB auth) would stop opening at that moment.
OpensslExtra::previous() reads config/openssl_extra.prev, the value a
node replaced and when it stops being accepted, and returns it while it
is still valid and differs from the value in use. Encryption::readToken
tries a token that fails with OPENSSL_EXTRA once more with that value.
Only the OPENSSL_EXTRA context falls back and only on the failure path,
so a valid token costs nothing extra. The file is read once per process;
until something writes it (the repair signal that follows), nothing
changes. encrypt/decrypt/seal/open/mintToken are untouched: new tokens,
hmac_keys and image names use only the current value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
LbInstallFlow::provisionConfig shipped only config.enc. A MAIN set up by
the current installer holds a random config/openssl_extra, so every LB
added with server:install ran on the built-in OPENSSL_EXTRA and rejected
the stream tokens MAIN mints for the redirects it serves (/auth, /vauth,
/tsauth, /thauth, subtitles), and could not decrypt MAIN's hmac_keys.
The new provisionOpensslExtra step removes any config/openssl_extra a
previous MAIN left on the host, then sends the MAIN's file when it holds
a value and makes it 0600. It runs after the config.enc upload and
before config/ is chowned to xc_vm, so php-fpm can read it; a failed
upload marks the server errored (status 4) like a failed config.enc.
When the MAIN has no file it runs on the built-in default, and so does
the LB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
OPENSSL_EXTRA keys the stream tokens MAIN mints for the redirects an LB
serves, and hmac_keys. It is config/openssl_extra, or the built-in
default when that file is absent. The current installer writes a random
value on a fresh MAIN, but nothing ships it to an LB, so an LB added
with server:install runs on the default and rejects every token MAIN
mints for it. Nothing showed which clusters are affected.
Each node's cron:servers now publishes a keyed 64-bit fingerprint of
its value (never the value) as server_hardware.openssl_extra_fp, in the
UPDATE it already runs every minute. server:diagnose compares the
node's fingerprint with the main's, from the main and on the node
itself, and reports "OPENSSL_EXTRA mismatch: tokens minted on MAIN are
rejected by this node". A node or main that does not publish one reads
as "unknown (... not updated)"; proxies are skipped. No behaviour
changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
Review follow-up to the bandwidth/rps/producers fix. The nginx stub_status
parse moves into SystemInfo::nginxRequestCount(), so the requests field the
watchdog samples is tested against a real status body and an unreachable
one (no count, rps 0). The getStats() test now pins the whole array_replace
statement (a `+` would leave the bandwidth keys at 0), and the sampler test
checks that 120 s is in range by default. No behaviour change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
SystemInfo::getStats() feeds the watchdog heartbeat (servers.watchdog_data),
servers_stats and the LB /api stats action, and had four defects:
- bytes_sent_total/bytes_received_total were assigned per interface, so
only the last selected interface was kept, and the received total read
tx_bytes. aggregateNetwork() now sums both totals over the same
interfaces as the per-second rates and reads rx_bytes for received.
network_speed keeps its first-positive rule and the interface selection
is unchanged, so routing capacity is not affected.
- total_running_streams was `ps ax | grep -c ffmpeg`, which counted
ffprobe and shell wrappers and missed the fanout daemon's native
remuxer. ProcessManager::countStreamProducers() now reads /proc and
counts processes whose program is ffmpeg or `xc_fanout remux`.
- The memory percentage divided by total_mem, which getMemory() reports
as 0 without MemAvailable (DivisionByZeroError), and nvidia-smi XML
with a single GPU process decodes process_info as one associative
array (TypeError). Either aborted the watchdog pass, so the node wrote
no heartbeat. memUsedPercent() and gpuProcesses() guard both.
The watchdog runs one pass per process and re-execs itself, so the
previous nginx request count held in a variable was always null and
requests_per_second was 0 on every node. CounterRateSampler keeps the
previous reading in a tmp state file (atomic replace, 120 s max age, 0
after a counter reset).
The watchdog_data key set and order are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
Review fixes for the php_pids change.
- cron:users judged a remote PHP-served connection by date_start, but a
reused connection (VOD range/seek, TS or timeshift reconnect) gets a new
worker pid and keeps its original date_start. A pid assigned after the
node's last heartbeat was then looked up in a list that could not hold
it, and the live viewer was killed. The pid write also stamps
hls_last_read (MAIN's clock), so the later of the two now dates the pid.
The check moves into isRemoteWorkerRunning().
- phpFpmWorkerPIDs() returns null when no FPM master is visible, so an
idle ondemand pool ("[]") now reaps dead workers' rows while an unknown
list still keeps them. A server missing from either map no longer hits
count(null).
- getAllSimple/getStreamingSimple/getProxySimple and the admin API
get_server leave out php_pids; getById keeps the full row for REPLACE.
- Tests pin the watchdog and reaper wiring, exercise sort(), and reset
the ServerRepository db seam; array_unique() is dropped (unreachable).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
A connection row records the PHP-FPM worker serving it (getmypid() in
live.php, vod.php and timeshift.php), but the watchdog published as
servers.php_pids the contents of bin/php/sockets/*.pid: the FPM master
pids. In Redis mode MAIN's cron:users reaps every node's connections
and, for a remote PHP-served connection older than the node's last
heartbeat, checks the worker pid against that list. It never matched,
so the connection was closed and the LB told to kill its worker: every
VOD, timeshift and PHP-served live viewer on an LB was dropped about a
minute after connecting. MySQL mode was unaffected (each node probes its
own pids locally).
- ProcessManager::phpFpmWorkerPIDs() lists the processes titled
"php-fpm: pool xc_vm" (sorted, unique); findProcessPIDs() takes an
optional procfs root so it can run against a fixture tree.
- WatchdogCommand publishes that list instead of the pid files.
- ServerRepository::getAll() drops php_pids from the rows it returns and
caches, so the per-request servers cache does not carry every node's
worker list. CacheCronJob's unset($rServers['php_pids']) meant to do
that but hit a top-level key of an id-keyed map; it is removed.
- UsersCronJob reads php_pids with its own SELECT `id`, `php_pids`.
The `- 1` heartbeat margin is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
live.php's Redis branch now calls ConnectionTracker::acceptedLineIP(),
which skips an empty (HMAC) line id without asking Redis and otherwise
returns the oldest active connection's IP. The HMAC guard and the
oldest-row choice are now covered by tests: a null id never queries
Redis, and unordered LINE# keys yield the earliest date_start's IP.
getLineConnectionRows() takes an int line id; its untested HMAC-identity
form is dropped. The source-grep bans are replaced by a check that
live.php calls the seam, the misnamed key-shape test is renamed, and the
redundant igbinary skip is removed (tests/bootstrap.php polyfills it).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
In Redis mode live.php took ConnectionTracker::getLineConnections() as
connection rows, but it answers the LINE# set's members: connection
keys (uuid strings). array_column() over them gave [], and
array_multisort([], SORT_ASC, $rConnections) threw "ValueError: Array
sizes are inconsistent" on PHP 8 as soon as the line had one active
connection, including the same viewer's next playlist refresh. The
request died with a 500 instead of being limited. For an HMAC token the
line id is null, so passing it to getLineConnections(int) was a
TypeError before any connection was counted.
Add ConnectionTracker::getLineConnectionRows(), which reads and
unserializes the rows behind a line's LINE# keys (an unreachable Redis,
a failed call or an unreadable payload reads as no connection), and
ConnectionTracker::oldestConnectionIP(), which picks the user_ip with the
earliest date_start. live.php now accepts that IP, as the MySQL branch
does with the first lines_live row. HMAC identities skip the check, as
they already do in MySQL mode where `user_id = NULL` matches nothing.
getLineConnections() and ConnectionLimiter are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
Review follow-up to the log-import fix (71108b20):
- Record last activity with one UPDATE per batch instead of an
INSERT ... ON DUPLICATE KEY UPDATE, so activity for a deleted line no
longer recreates it as an empty `lines` row. Each line is set once,
from its latest row, in id order so nodes lock rows alike, and
`updated` is kept so cache_engine does not rebuild the line.
- Flush a batch at 4 MiB of spool as well as at 1000 rows, so oversized
client rows cannot push an INSERT past max_allowed_packet.
- Skip a claimed spool that cannot be opened instead of failing on fgets.
- Document that a claim left by a crashed run is re-imported from its
start (at-least-once).
- Tests: writer appending mid-import, missing keys, the server_id check,
last_activity_array contents, duplicate lines in a batch, byte-split
batches, the lines_logs failed-INSERT path; reset the injected DB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
ActivityCronJob and LinesLogsCronJob drain a base64(JSON)-per-line spool
(LOGS_TMP_PATH/activity, client_request.log) into MySQL once a minute on
MAIN and on every LB. Both parsers broke out of the read loop after the
first row, and ActivityCronJob also stopped at the first invalid row
instead of skipping it. The caller then deleted the whole spool, so each
node kept at most one lines_activity row and one lines_logs row per
minute. The rest were lost, and lines.last_activity was updated for one
closed connection only. In LinesLogsCronJob a line that did not decode
to an array raised a TypeError in array_map() before the unlink, so a
bad line wedged the spool.
Read every line, skip empty, undecodable and (for activity) incomplete
rows, and insert in batches of 1000 so a busy minute stays well under
max_allowed_packet. Each activity batch upserts `lines` with its own
consecutive ids from last_insert_id(). Values come from a fixed key list
with a '' default, so a record with a missing key cannot break the SQL
for its whole batch. The SQL statements and column lists are unchanged.
The spool is now claimed by renaming it to <spool>.import before it is
read. The writers append by path, so rows written during the import go
to a fresh spool and are not deleted with the claimed file. A claimed
file left by a crashed run is imported first. Rows are still dropped
when their INSERT fails; loss-free delivery is planned for Phase 5.
StreamsLogsCronJob and ErrorsCronJob already read every line and are
unchanged. ErrorsCronJob keeps error_log.log when every line is
filtered or its INSERT fails, and StreamsLogsCronJob unlinks even when
its INSERT fails. Both are left for a separate change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
Rollback previously swapped in older code but never touched schema
changes newer migrations applied — the rollback's own comment admitted
"migrations are forward-only, so a downgrade cannot undo them". Of the
25 core migrations, two actually DROP a column older code still reads
(013_drop_asn_allocated, 016_per_repo_update_channel); rolling back
past either without restoring it would crash the restored old code.
Add MigrationRunner::rollback(): diffs the currently-applied migration
set (the `migrations` tracking table) against the TARGET version's own
migrations/database/up/ file list — read straight out of the
already-downloaded rollback tarball via the new
ReleaseArchiveInspector (PharData/tar, no extra network call, no full
extraction) — and reverses, in descending order, every migration the
target version doesn't carry. Only migrations with a down.sql
counterpart are reversed; migrations lacking one are left applied and
logged as skipped, which is safe by design since every migration
without a down file only ever ADDs schema or business data that older
code simply never references.
Restructured src/migrations/ into database/up/ (the 25 existing
forward migrations, unchanged content) and database/down/ (18 new
reverse migrations: the two critical drops plus every mechanically-safe
ADD-column/cron-row inverse; excluded are three migrations touching
real business data — activation_codes, category_templates, lines'
custom_data — and four data UPDATEs with unrecoverable prior values).
Kept as two directories rather than a `.down.sql`-suffixed sibling of
the up file: glob('*.sql') in run() would otherwise pick up the down
file too (it still ends in .sql) and try to run it forward.
Wired into UpdateCommand's rollback case right after the tarball
download+MD5 verification (MAIN only, aborts the rollback on failure —
the pre-rollback DB backup remains the recovery path either way).
Also extends the shared TestDb harness (tests/Support/TestDb.php) to
strip MySQL-only ENGINE=/CHARSET=/COLLATE= table-option clauses under
its default SQLite backend, so MigrationRunner's real production DDL
can be exercised in tests without a live MariaDB.
Core/Http/RequestGuard.php was a procedural prelude, called only from
WebApiBootstrap::init(), that duplicated logic already living as proper
BootStageInterface classes used by every other BootContext:
FloodProtectionStage and HostVerificationStage. Reuse those directly;
add WebApiLoggerStage for the one real behavioral difference (WebApi's
PHP_ERRORS also honours the runtime `debug_show_errors` setting, not
just DEV_MODE like the shared ConstantsStage). RequestGuard.php is
deleted — it had exactly one caller.
Also removes two redundant manual `require_once` of already-autoloadable
classes in DatabaseLogger/FileLogger (same namespace as LoggerInterface,
loaded automatically on the `implements` reference).
Adds a BootStageTest case for WebApiLoggerStage, restoring the previous
error/exception handlers afterward since Logger::init() registers global
ones as a side effect.
Resellers' sidebar was a hardcoded PHP array in
layouts/reseller/header.php with no extension point, unlike admin's
NavbarRegistry/NavbarItem/CoreNavbarProvider. Add a sibling
ResellerNavbarRegistry + ResellerNavbarProviderInterface (mirroring the
existing PermissionRegistry precedent for reseller-specific module
extensions), migrate the hardcoded menu into CoreResellerNavbarProvider,
and wire both into ModuleLoader::bootAll() next to the other
registries. Modules now add a reseller sidebar entry the same way they
already do for admin.
Design: docs/superpowers/specs/2026-09-24-reseller-navbar-registry-design.md
Dashboard half of Rosmi720/XC_VM@8fa1e67f, which 78d7b232 left out over
tenant scoping. Same three panels, built on the endpoints that already
enforce the reseller's scope instead of on fresh SQL in the controller.
- Connections by Location: world map plus a top-countries breakdown, from
one `lines_activity` query joined onto `lines`. Gated on
save_closed_connection + dashboard_map and only ships jsvectormap once
there is something to paint, mirroring the admin dashboard.
- Live Connections: the ./table?id=live_connections serverSide table the
Live Connections page uses, five rows, kill button included. Rendered
only with reseller_client_connection_logs.
- Recently Added: Streams / Movies / Episodes tabs over
./table?id=streams|movies|episodes, newest first. Rendered only with
can_view_vod.
- Recent Activity and Expiring Lines become DataTables (sort, search,
paging) instead of fixed-height scroll boxes.
- Activity rows: text-dark / text-secondary -> text-body /
text-body-secondary. The Bootstrap 5.3 `text-dark` is near-black, so
those links were unreadable on the dark skin.
- Tolerate a request that lost its user: the controller and the shell now
fall back to '' / 0 instead of fataling on $rUserInfo['username'] and
['member_group_id'].
Changed on import:
- No unscoped fallbacks. Upstream re-ran the country, movie and live
stream queries without the tenant filter whenever the scoped one came
back empty, and never scoped series or episodes at all, so an empty
reseller saw the whole panel's data. Empty scope now means empty panel,
which is what ResellerTableRenderer does everywhere else.
- Drop the hardcoded 'EG' fallback for connections GeoIP could not place;
those rows are left out of the map instead of being pinned to Egypt.
- $rCanVod reads the can_view_vod permission. Upstream set it to a literal
true, which showed the media panels to resellers without VOD access.
- No CategoryService::getAll() alias and no category-name resolver: the
table endpoints already return a resolved category per row.
- No stream_view links. That route is registered for admin only, so the
per-row links and View buttons upstream added would 404 for a reseller.
(Seven existing reseller views have the same dead link — separate fix.)
- Only the three strings the panels ask for, not all 138 upstream keys;
the rest are already present or unreferenced.
Verified: 869 tests, make gates, CRAP gate, PHPStan. Visual layout still
wants a browser pass.
Refs: #148
Last of the three items Rosmi720/XC_VM@8fa1e67f left over, deferred in
78d7b232 because the CRAP ratchet refuses hardening an uncovered cc=53
method. Extracting the guard instead of inlining it keeps the sniff happy
and gives the batch read a home the other six call sites can move to.
ResellerTableRenderer::handleLiveConnections iterated
RedisManager::instance()->mGet($rKeys) directly. RedisManager::instance()
returns null when Redis is unreachable, which makes that a fatal; mGet
answers false on a dropped connection and the client itself while
pipelining, which makes the foreach a warning; and a reseller with no
lines sends an empty key batch. Admin\TableController already carries the
guard — this moves it onto ConnectionTracker::readConnections(), which
already owns the LINE# reads two lines above.
Also swap the `array_multisort` order-column test to !empty(): the client
picks the column by index, so it can name the Responsive control column
(index 0, `false`) or one past the end of the map, and array_multisort on
an undefined key is a TypeError.
Verified: 869 tests, make gates, CRAP gate, PHPStan.
Refs: #148
installModule() ended with an unconditional setState(Enabled). Store updates,
zip uploads and plain reinstalls all pass through it, so switching a module off
in the panel survived only until its next update — which is how a disabled plex
came back and then blocked `watch` from being disabled at all.
The admin's choice is now captured before the Installing transition overwrites
it and restored at the end; a first install still enables, since there is no
choice to preserve. stateAfterInstall() reads the raw override rather than
listModules() so the transient Installing / Failed states cannot be mistaken
for a deliberate "disabled".
Restoring that state uses the new writeState() instead of setState(): putting a
module back into the state it already had is a lifecycle transition, not an
admin "disable", and must not be vetoed by the dependents guard — otherwise
updating a disabled `watch` would fail at the last step because `plex` exists.
setState() keeps the guard for the deliberate case.
Three tests cover it; two of them fail against the previous behaviour.
Verified: 860 tests, make gates, CRAP gate.
Mechanical output of `make rector-fix` over the class-based trees, reviewed
change by change. Most of it is empty()/=== [] normalisation, a chained === turned
into in_array(..., true) in FanoutConfig, and multi-line condition reformatting.
Three changes were checked more closely because they looked behaviour-affecting:
- Translator::isRtl() loses its (string) cast on $lang. Safe: $currentLang is a
non-nullable `private static string`, so the ??= above always leaves a string.
- TableController loses htmlValue() and jsArgument(). They are escaping helpers,
so the removal was verified rather than trusted: neither has a single reference
anywhere under src/, views included. Genuinely dead.
- StartupCommand and StreamsCronJob have three private helpers turned from static
into instance methods (LocallyCalledStaticMethodToNonStatic).
That last one broke 7 tests, which reached the helpers through Reflection and
invoked them statically. Fixed in the tests rather than by reverting the rule —
reverting would only lose the argument again on the next `make rector-fix`. The
Reflection helpers now bind an instance only when the method needs one, so they
survive the flip in either direction.
Also re-ran the two KNOWN BUG greps from build/rector.php (the rules that drop
parens around an assignment-in-condition and invert the guard): both clean.
Verified: 857 tests, make gates, make cs, CRAP gate.
Imported from Rosmi720/XC_VM@260ca7ab, with the scoping and permission gaps
closed before merge.
Original work:
- Keep tickets listed through a LEFT JOIN so one does not vanish when its
author's user row is edited or deleted; username falls back to 'Unknown'.
- Stop double-escaping message bodies: the repository returned
htmlspecialchars() output that both ticket_view templates escape again, so
markup showed up as entities. The repository now returns raw text and the
views keep their nl2br(htmlspecialchars()).
- Drop the str_repeat(' ', ...) padding hack that rendered literal
' ' inside short replies.
- Resolve last_reply and the derived status flags without undefined-key
warnings on tickets that have no replies yet.
- Add Close / Re-Open to the admin ticket view with a confirmation dialog, a
quick reply form under the thread, and a Reply entry in the tickets table.
- Add English and Arabic strings for the confirmation dialogs.
Changed on import:
- Tenant isolation: the original widened the admin branch to every ticket on
the server whenever $rAdmin was set, and admin/tickets.php always sets it.
Since users_groups.is_admin is a flag several groups can carry, that exposed
every tenant's tickets to any admin-panel user. Only the super-admin group
(member_group_id = 1) now gets the unscoped list; any other admin stays
scoped to the users it owns. TicketVisibilityTest covers all three scopes.
- Gate Close / Re-Open, the quick reply form and the Reply links behind the
same Authorization::check('adv', 'ticket') the tickets table already used.
- Quote 'Unknown' as a string literal, not "Unknown", which ANSI_QUOTES would
read as an identifier.
- Escape the textarea placeholder.
- Drop the admin/functions.php hunk: that file no longer exists after the PSR-4
move and AdminApiStage already populates $GLOBALS['rAdminUserInfo']. The
post.php fallback is kept, since post.php can be served standalone.
Verified: 857 tests, make gates, CRAP gate.
Disabling a module whose own dependency was already off was impossible: the
guard counted a dependent's nominal Enabled state, while ModuleLoader prunes
unsatisfiable modules transitively. A dependent sitting on an already-broken
chain is not running, so it must not block. The guard now mirrors the loader's
prune and only counts dependents that would genuinely stop working.
Alongside that, three lifecycle holes found while reviewing ModuleManager:
- updateModule() had no failure handling and no incremental watermark. A
migration failing half-way left the module Enabled at the old version, so a
retry replayed already-applied deltas — a non-idempotent one then failed
forever. File deltas and programmatic migrations are now merged into one
ascending timeline, the recorded version advances per COMPLETED version, and
a failure marks the module Failed. ModuleMigrator gains pending() and a
public applyFile(); up() is reimplemented on top of them, unchanged.
- uninstallModule() had no try/catch, unlike installModule(). A failing
uninstall() hook left the module installed and Enabled with half-deleted
data. It now marks the module Failed. The duplicated dependents guard moves
into assertNoInstalledDependents(), shared with deleteModule().
- Platform (store) installs assumed a bare modules/{slug} directory while every
other path uses {name}_{hash5}. The backup step then found nothing and the
pulled copy landed beside the existing install, leaving two manifests for one
module. The platform flow now reuses placeModuleFiles(), which drops rival
copies and moves the source into place instead of copying it; listModules()
de-duplicates by name so a stray copy cannot show the module twice.
archivesPath now sits beside modulesPath rather than resolving under MAIN_HOME,
so an injected modules path is honoured (identical path in production).
Verified: 852 tests, make gates, CRAP gate.
Port of the RTL slice of Rosmi720/XC_VM@f34b4808. When the active language is
right-to-left (ar/fa/ur/he), the admin and reseller shells and the login page
render mirrored:
- Translator::isRtl() reports whether a language (or the current one) is RTL.
- admin/reseller headers and login set <html dir="rtl"> and load the Cairo web
font + assets/xcvm/rtl.css only for RTL languages (a no-op for LTR).
- rtl.css mirrors navigation, cards, tables and forms.
- Arabic language file (ar.ini) so an RTL language is actually selectable.
Adapted to this tree: only the language-driven RTL was taken (not the fork's
navbar language switcher or the per-user RTL toggle); isRtl is unit-tested.
Port of Rosmi720/XC_VM@0d39e9bd. A mode-dropping deploy could leave core
scripts non-executable and a php-fpm pool config missing, and an undefined HOST
constant broke the unique-header cookie domain:
- StartupCommand restores the executable bit on service/update/redis-server/
daemons.sh and regenerates any missing php-fpm pool config (1..4.conf) from
the template on boot.
- StreamAuthMiddleware derives the cookie domain through a HOST-guarded helper,
so an undefined/empty host or a bare IP yields no domain instead of throwing
or emitting a broken "." domain.
Adapted to this tree: the added boot blocks were split into covered helpers
(ensureExecutableScripts / ensurePhpFpmPoolConfigs / uniqueHeaderDomain) with
unit tests to stay within the complexity ceiling, the sudo chown now runs only
as root, and comments were translated to English.
Port of Rosmi720/XC_VM@b44abddf. The rogue-connection sweep assumed
ConnectionTracker::getConnections() always returned a MySQL map grouped by
user id and iterated it with a flat double-loop. In Redis mode it returns a
[keys, data] pair, so the inner loop hit string keys and threw a TypeError,
aborting the cron.
Parse the result defensively via a small recursive helper (collectUuidRows)
that flattens any of the shapes — the Redis [keys, data] pair, the MySQL
user-id map and a single connection row — to the rows carrying a uuid, then
filter to the current stream. Both helpers are pure and unit-tested.
Adapted to this tree: the ar.ini/en.ini label tweaks target keys not present
here, so only the cron fix was ported; the added guards were split into
covered helpers to keep loadCron within the complexity ceiling.
Port of Rosmi720/XC_VM@4e91fb68. Encrypted playlist entries carried the output
extension as a URL fragment (play/<token>#.<ext>), which the client strips and
nginx never sees, so every encrypted stream fell back to .ts — VOD/HLS were
effectively broken. Encode the extension in the path instead:
- PlaylistGenerator emits play/<token>.<ext> for live and VOD (Cloudflare TS
keeps the bare token). The live/VOD suffix is a small pure helper
(encryptedPlaySuffix, unit-tested) so generate() stays within its ceiling.
- nginx rewrites play/<token>.<ext> to the auth handler, keeping the legacy
play/<token>/<ext> and bare-token rules for already-saved playlists.
- stream/auth.php splits a <token>.<ext> token and fills the extension when
nginx did not.
- settings label uses the new encrypt_playlists key (en.ini).
Adapted to this tree: ar.ini is absent here, so only en.ini was updated.
Port of Rosmi720/XC_VM@2140b00f. A missing/corrupt MaxMind database or an
undefined HOST constant could fatal the stream request (white screen on live):
- GeoIPService::getIPInfo()/getISP() no longer construct the MaxMind reader
unguarded. A missing constant/binary or any reader error now yields false
instead of a fatal, via a shared readMmdb() helper (unit-tested).
- StreamRedirector::getStreamingURL() reads HOST through a defined() guard so
an undefined constant does not throw while resolving the broadcast domain.
Adapted to this tree: the added guards/try-catch were folded into readMmdb so
getIPInfo/getISP stay within the complexity ceiling.
Port of Rosmi720/XC_VM@8f41d206. Harden the streaming user-info path against
null/malformed data that caused a fatal TypeError on player login:
- ImageUtils::validateURL() accepts null/empty (nullable icon columns) → ''.
- UserRepository::decodeUserFields() tolerates already-decoded arrays, null
columns and malformed JSON (via a shared decodeJsonField helper).
- aggregateBouquetIds() skips non-array bouquet groups (mergeBouquetGroup
helper); getStreamingUserInfo() resolves the bouquet map from the shared
cache / DB when the caller passes none (resolveBouquets helper).
- PlayerApiController::getOutputFormats() guards a non-iterable input and
falls back to the full format set.
Adapted to this tree: kept the existing null-safe category_map / active_cons
handling, and split the added guards into small covered helpers to stay within
the complexity ceiling (unit test for resolveBouquets).
Add category-template selection to the line, mag and enigma edit forms and to
the reseller active-code (voucher) form, plus a reseller navbar entry. Extract
the shared custom_data resolver into CategoryTemplateService::applyCustomData()
and route the Line/Mag/Enigma saves through it, removing the triplicated inline
block; covered by a new unit test.
Review fixes folded in:
- getRecentBatchNames filtered a non-existent `user_id` column and used
DISTINCT + ORDER BY id (rejected by MySQL ONLY_FULL_GROUP_BY). Restore the
correct created_by + GROUP BY MAX(created_at) query (kept the empty-batch
guard).
- routes/admin.php: import ActiveCodeDetailsController (fatal short-name use
without it) for the active_code_details route.
- Revert the accidental re-inline of the ancestorOwnerIds() helper in
getTemplatesForUser and the getResellersWithCodes all-users fallback.
Also: settings save only zeroes checkbox flags on the full settings form
(submit_settings), and reseller bootstrap tolerates a missing language.
Ported from PR #198 (Rosmi720/XC_VM): shared category templates were only
visible to the sharer's direct sub-resellers. Widen visibility to the whole
hierarchy so a template shared by any ancestor reseller reaches every
descendant.
- getTemplatesForUser: walk the ownership chain upward (new cycle-guarded
ancestorOwnerIds helper) and match is_shared templates owned by any
ancestor, not just the direct parent.
- canAccessTemplate: accept a shared template when the caller sits anywhere
in the owner's sub-tree; this subsumes the old direct-parent check.
- Reseller list: add a "Shared with Me" scope tab + count, driven by the
existing scope_type='shared' classification.
- Add CategoryTemplateVisibilityTest covering ancestor visibility and the
access guard (also keeps both methods under the CRAP ratchet).
Deliberately excluded from PR #198: the admin category_template(s) view
rewrites (a competing older implementation that reintroduces the xcToast
argument-order bug and drops the ?create=1 dialog deep-link already fixed
locally), and its language/RTL/active-code/dashboard changes (out of scope).
`make main`/`make lb` now stamp a unique per-build id — version + short git
SHA + UTC timestamp + 8 random bytes — into RELEASE_ID at the staged deploy
root (new stamp_release_id target, run after the copy step; the file is
generated per build, never git-tracked). ConstantsInitializer exposes it at
file scope as XC_VM_BUILD_ID by reading RELEASE_ID relative to __DIR__; a
source/dev checkout has no file and reports 'dev'.
This gives every build a traceable fingerprint so a leaked or rebranded copy
can be tied back to its origin (and, once activation ships, reported in the
phone-home alongside install_id). Kept at file scope to avoid adding a branch
to ConstantsInitializer::init() (CRAP ratchet). README discloses it.
Tests: XC_VM_BUILD_ID defined and defaults to 'dev' in a source checkout.
Full suite (817) + gates + CRAP + phpcs green; Makefile targets validated.
Adds LicenseGate::fanoutUsable() — the single "should this request use the
fanout daemon?" check (control socket present AND licence permits it). The two
fanout choke points now consult it instead of bare file_exists(FANOUT_CTL_SOCK):
StreamProcess::supervisionEnabled() and the three delivery tests in
Public/stream/live.php. When it returns false, live streams fall back to the
legacy delivery path automatically (soft enforcement) — nothing is hard-blocked.
The licence verdict itself (HWID-bound key tied to install_id, hidden
method-selector bytes, weekly re-validation, 7-day offline grace) lives in the
compiled xcvm_core extension; PHP only reads it via \XC_VM::license_valid()
when present. Fail-open where the extension or method is absent (dev/CI and
installs without xcvm_core), so this is behaviour-preserving until the C side
ships the verdict — and never bricks a working panel.
Helper kept in the new LicenseGate (not FanoutClient) to avoid touching that
debt-heavy file. supervisionEnabled() simplifies (cc 4->2). README disclosure
for activation will land when the verdict goes live.
Tests: fail-open without extension, unusable without control socket. Full
suite (816) + gates + CRAP + phpcs green.
Adds a disclosed, reversible attribution-integrity check (AGPL-3.0 §7(b)).
The "Vateron Media · AGPL-3.0" credit now has a single source
(AdminHelpers::getAttribution(), reused by getFooter). AttributionGuard
verifies its markers; AttributionVerificationStage runs the check on the
Admin (UI) boot profile only — admin/reseller/player panels — and calls
generateError('ATTRIBUTION_REMOVED') when the notice is gone.
Scope is deliberately UI-only: streaming is NOT gated, so removing a UI
footer cannot cut off a reseller's end-viewers (that is the licence
layer's concern). The lock is non-destructive and auto-reverses — the CLI
stays usable and restoring the notice unlocks the panel on the next
request. Enforcement is documented in README (License Enforcement).
This is a readable-PHP deterrent (a determined actor can remove the check
with the notice); tamper-resistant enforcement belongs in the compiled
xcvm_core extension (planned).
Tests: attribution markers, hasMarkers tamper detection, CLI self-skip,
UI-only wiring, error-code registration. Full suite + gates + CRAP green.
The cs-fix pass (758a9cab) typed ImageUtils::validateURL()'s URL and
StreamSorter::formatTitle()'s title as non-nullable strings, but both
come from nullable columns (streams.stream_icon / stream_display_name,
streams_series.title / cover, …). get_live_streams passed a channel's
NULL stream_icon straight in, so one channel without an icon turned the
line's entire live list into a TypeError page — and the same for any of
the ~100 validateURL() callers (series covers, backdrops, episode
images, admin and reseller views) and a title-less stream or series.
Both now take null as '' (unit-tested). player_api also no longer hands
filterLoaded() a null when the categories cache file is missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4P9p5BXijMoGXd31tN6Zp
player_api loads the bouquet list only for the three stream-list
actions and passes null otherwise, but the cs-fix pass (758a9cab) typed
getStreamingUserInfo()'s $rBouquets as a non-nullable array. So the
sign-in call every app makes first, the category calls, the EPG and info
calls — even a wrong password — died with a TypeError page instead of
JSON: no player could sign in. The Ministra portal passes the cache's
null the same way when the bouquet cache file is missing.
Accept null as "no bouquets". Also skip a bouquet newer than the
category map (built by the heavy cache pass) instead of warning about an
undefined key — that warning alone broke the JSON where errors display.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4P9p5BXijMoGXd31tN6Zp
Views/admin/functions.php duplicated, almost line for line, the body of
AdminScopeBootstrap::bootFunctions() (the front-controller admin boot), and
was also include'd by the JSON DataTables endpoint — where its cookies /
redirects / setup-COUNT query are inappropriate and the relative include was
fragile (only resolved via the FC's @chdir).
- AdminScopeBootstrap: expose the boot body through a public
hydrateAdminContext() entry point; guard the setcookie()/header() calls with
headers_sent() so the view scripts can call it after output has begun;
validate the session via the shared predicate.
- SessionManager::adminSessionValid() is now the single definition of the admin
session-integrity check (user/is_admin + login-IP + verify hash), split into
adminIdentityValid()/adminIpAllowed() and covered by SessionManagerTest.
- The six $noBootstrapPages view scripts (login, logout, setup, database,
player, post) call AdminScopeBootstrap::hydrateAdminContext() in place of
include "functions.php", re-importing the view-facing globals in their own
scope (the load-bearing part of the old include).
- Admin TableController: the api_key / api_user_id / session branches share a
hydrateApiUser() helper; the session branch validates via adminSessionValid()
and returns JSON on failure (no cookies / redirect / setup query). index()'s
global is widened so Authorization::check sees $rUserInfo/$rSettings.
793 unit tests pass; make gates and the CRAP gate stay green.
FanoutConfig::desired maps a new fanout_debug setting to the daemon's debug_cats
config key: "" (off, the default), "all", or a comma-separated category list
kept to the daemon's known categories (unknown names dropped, so a typo turns
debug off rather than writing a file the daemon second-guesses). The daemon
applies it on its next config poll, so an operator can turn debug on for a
misbehaving node and off again without a restart that would drop every viewer.
The PHPUnit suite could not be run here — no PHP on this box has the
dom/mbstring/xml/xmlwriter extensions phpunit.phar needs — so the three tests
added to FanoutConfigTest are unrun; the debugCats() mapping was instead
verified directly by reflection (off/all/list/unknown all map as asserted).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two control calls the daemon has always answered and the panel never made.
FanoutClient::memory() reads GET /memory, and status() now carries it — so it
lands in servers.watchdog_data through the watchdog that already writes that
array, and the admin server views read it back by key with no view change. The
daemon's RSS is dominated by the per-stream join rings, by design: one watched
channel holds its whole prebuffer. Until now the panel could see that a node
was running hot and nothing more, and "the rings are as big as the tuning says"
and "something else is growing" are different problems with different answers.
FanoutClient::unregisterAll() reads DELETE /streams, the bulk teardown for a
maintenance drain or a decommission, instead of scripting a DELETE per id. The
daemon logs each teardown, so it stays an auditable operator action.
Both answer null when the daemon is unreachable, which for the watchdog is an
ordinary state rather than an error, and status() skips the memory call when
the socket did not answer.
Verified by loading the class directly: with no control socket, status() keys
are running,socket,connections,memory with memory null, and both new calls
answer null. The PHPUnit suite could NOT be run here — no PHP on this box has
the dom/mbstring/xml/xmlwriter extensions phpunit.phar requires — so the two
tests added to FanoutClientTest are unrun; they assert exactly what the harness
above printed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Semgrep (php.lang.security.exec-use) blocked stopMovie: it killed the
encode with a `kill -9 $(ps | grep -F <path>)` pipeline and deleted the
files with `rm <path>.*`, both built from strings. The inputs were an
int id and a constant path, but nothing in the code proved that to a
scanner, and one careless edit would have made it injectable.
Find the encodes through /proc with ProcessManager::findProcessPIDs and
kill them with ProcessManager::kill; delete the files with glob() and
unlink(). Behaviour is unchanged: the trailing dot still keeps movie 5
from matching movie 50, and a no-transcode movie's symlink is removed
without touching its source.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0135TSeD5tJGsD9peTZskAVM