Three fixes from the automated PR review:
- ErrorResponder::respondError() used `$httpCode === null` where the legacy
generateError() used `!$rCode`; a caller passing 0 now falls through to the 404
page again instead of emitting http_response_code(0). (+ test)
- StageProfiles::for(BootContext::WebApi) now throws instead of silently building
a wrong stage list from the common prefix/suffix — WebApi boots via
WebApiBootstrap, never the kernel. (+ test)
- LegacyCoreStage reads enable_cache via SettingsManager::getBool() (the typed
getter the Web API path used), rather than the raw get(). Behaviour is
equivalent (`!` already coerces) but the intent is clearer.
Add cheap unit tests for stages that need no DB or config extension:
SessionStage / FloodProtectionStage / HostVerificationStage self-skip under the
CLI SAPI, ProcessTitleStage and AdminShutdownStage run without error, and
StatusConstantsStage defines the STATUS_* codes. Unit-covered stages: 8 of 16
(the rest need a live MySQL + xcvm_core and are covered by the dev-container and
real-install smokes).
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
StreamService::process() runs the stream icon through
ImageUtils::downloadImage() when "download images" is on, and an empty icon
arrives there as null. The `string` type the cs-fix pass (758a9cab) put on
the parameter made that a TypeError, so saving a new stream with no icon
answered an empty page and created nothing. Series, movies, episodes, radios
and created channels hand it optional covers and backdrops the same way.
downloadImage() now takes null and hands back whatever it cannot download
unchanged, null included. Found by the new E2E stream test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WDhDajBPziJwjWZcXnh6R
WebApiBootstrap now reuses DatabaseStage + LegacyCoreStage for the "connect, wire
the domain services, run initCore, reconnect if the settings cache is incomplete"
step, so that logic has a single source of truth shared with the main kernel.
The web-API-specific parts stay inline because they are order-sensitive and not
container-based: the prelude split around the ini_set defaults, RequestGuard
(flood/host/PHP_ERRORS/Logger from the file cache), and the $gitRelease global.
The redundant explicit require_once of LegacyInitializer/DatabaseHandler/
GitHubReleases is dropped — those autoload. The endpoint cache list is now a
class constant.
Adds BootContext::WebApi (with a BootKernel::defaults arm for match
exhaustiveness) as the state's context; WebApi builds its own two-stage pipeline
rather than going through BootKernel, so no container context/options are set —
preserving the previous behaviour exactly.
Verified in a php+MariaDB dev container: WebApiBootstrap::init('api') populates
$db/$gitRelease/$rSettings, defines PHP_ERRORS/SERVER_ID, and the booted handle
queries the DB.
MagService::getById() and EnigmaService::getById() look up the paired line
with UserRepository::getLineById($rRow['user']['pair_id']), and pair_id is
NULL for a device without a pair. The `int` type the cs-fix pass (758a9cab)
put on getLineById() made that a TypeError, so loading such a device —
deleting it, among others — answered an empty page and changed nothing.
Found by the new E2E device test.
getLineById() is also fed activation codes' nullable subscriber_id and raw
request values, so the guard lives there: anything that is not a positive id
finds nothing, as the untyped version did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WDhDajBPziJwjWZcXnh6R
post.php hands its optional `referer` parameter to
AdminHelpers::getPageFromURL() on every edit (streams, movies, created
channels, episodes, lines, MAG, Enigma2, radios, series, resellers). The
new-UI forms post without one, and the `string` type the cs-fix pass
(758a9cab) put on the parameter turned that null into a TypeError: the save
answered an empty 200 and the form showed its error toast. Found by the new
E2E line-edit test.
The function already treated an empty URL as "no page"; it now takes null the
same way, and a URL without a path no longer reads an undefined `path` key.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WDhDajBPziJwjWZcXnh6R
Replace the fully-static XC_Bootstrap god-class with a stage pipeline so the boot
logic becomes unit-testable and the per-context sequences are explicit.
- BootState replaces the 8 static readiness flags with a value object threaded
through the pipeline; stages read/write it instead of static state.
- BootStageInterface + BootPipeline run an ordered stage list and abort loudly
on a throwing stage.
- 16 stages under Core/Bootstrap/Stage/ hold one subsystem each, extracted
verbatim from the old private methods (constants, config, flood, host, session,
database, legacy core, redis, process title, admin API, translator, admin
shutdown, status constants, admin globals, container populate, health check).
- StageProfiles builds the ordered list per context, mirroring the exact previous
sequence; BootKernel resolves options, sets up the container and runs it.
- XC_Bootstrap is now a thin BC facade delegating to BootKernel; its getters read
the returned BootState. reset() also clears EventDispatcher and the new
DatabaseFactory::reset() (a side-effect-free registry clear for test isolation).
The DB-touching contexts (Cli/Stream/Admin) still require a live MySQL and the
xcvm_core extension, so they are verified on a canary rather than in CI; the
Minimal context and the pipeline/profile composition are covered by new tests.
Introduce the source-of-truth classes for the bootstrap testability refactor.
Purely additive — nothing is wired to them yet.
- ConstantsInitializer: pure value maps (paths/appConfig/binaries/statuses)
plus the single define() site (init/initStatus). The maps evaluate with
different MAIN_HOME/BIN_PATH in one process, which the one-shot define()
constants they feed cannot — this is what makes them testable.
- ErrorResponder: the generateError()/generate404() logic extracted into pure
codes()/renderDebug()/render404()/respond*() plus a single side-effecting
emit(). A test-mode toggle throws ErrorResponseException instead of exit().
- ErrorResponseException: value carrier for a resolved error response.
OPENSSL_EXTRA is now sourced per-install via ConfigReader with a mandatory
fallback to the historical literal, so existing installs (whose persisted data
derives from it) keep decrypting; generation-at-install is left to the installer.
Verified byte-for-byte against the legacy prelude before wiring: 53/53 constants,
debug/404 HTML frozen as sha256 goldens, 65 error codes.
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.
ChannelService (the video-channel twin of RadioService) was among the
most Rector-churned untested classes: the pass inverted many empty-else
branches across its server-tree, transcode and flag handling. Lock that
behaviour with characterization tests over the fully-runnable paths
(massEdit has no verifyPostTable/exit; StreamProcess::updateStreams is a
no-op with cache off), exercised through the dual-backend TestDb:
- transcode flag derivation (c_transcode_profile_id -> enable_transcode
1/0 by sign),
- boolean flag columns (checked => 1, edited-but-absent => 0),
- server-tree ADD (root '#' skipped, 'source' parent stored as NULL),
- setOrder writes a sequential order by the posted id list.
726 tests green.
TestDb can now run the DB-touching unit tests against a real MariaDB as
well as the default in-memory SQLite, so the suite can be exercised on
the panel host (which ships pdo_mysql, not pdo_sqlite). When
XCVM_TEST_DB_DSN is set it connects there and translates the SQLite test
DDL on the fly: AUTOINCREMENT -> AUTO_INCREMENT, a bare INTEGER PRIMARY
KEY gains AUTO_INCREMENT, and each CREATE TABLE is preceded by DROP TABLE
IF EXISTS (a MariaDB schema persists across the per-test connections that
:memory: starts fresh). DDL is routed through exec() on both backends so
the ModuleMigrator path (which runs DDL via query()) works too, and the
MySQL session uses a permissive sql_mode to match SQLite's leniency.
AuthRepositoryTest back-quotes the reserved column `key`.
Three env-fragile guards are tagged #[Group('skip-on-panel')] so the
deployed-panel run can exclude them (--exclude-group skip-on-panel):
ArchitectureTest and StreamTokenCallSitesTest scan the repo src/ tree
(absent / polluted in a flat deploy; they also self-skip when it is
missing), and LoginSessionFixationTest runs in isolated child processes
that the panel's ionCube/OPcache PHP cannot reconstitute.
Verified green on all three backends: SQLite (local, 721), MariaDB 11.4
(container, 721), and the panel's bundled PHP 8.1 + server MariaDB
(713, with the group excluded).
Reviewed Rector batch (3 files) + a manual decomposition of massEdit on top.
Rector batch (reviewed against suite + PHPStan):
- FIX (recurring Rector bug): the boolean inversion again dropped the parens on
an assignment-in-condition in massEdit (array_search category DEL) — restored.
- massEdit: many correct empty-if/else -> guard inversions (verified).
- strlen(x) > 0 -> x !== '' in saveStreamOptions, TMDbService::buildUrl and
ResellerTableRenderer (behaviour-preserving).
massEdit decomposition (166 -> 96 lines, nesting ~10 -> 7), test-first:
- computeCategoryChange() — ADD union / DEL remove / SET replace (pure)
- planBouquetChanges() — SET/ADD/DEL bouquet attach/detach plan (pure)
- planServerTreeForStream()— per-stream streams_servers reconcile (update in
place / batch-insert buffer / delete marking)
- raiseTimeLimits() — the set_time_limit + ini_set block, shared with
massDelete (de-duplicated)
Tests: RadioServiceTest gains computeCategoryChange (5) + planBouquetChanges (3);
new RadioServiceMassEditTest characterizes massEdit end to end (invalid input,
and the server-tree ADD path) — it has no verifyPostTable/exit and
StreamProcess::updateStreams is a no-op with caching off, so the whole method
runs against the SQLite harness. That test guarded the server_tree extraction.
PHPStan level 5 clean; suite 721 tests / 0 errors.
process() was a 250-line, ~10-deep method riddled with the empty-if/else
anti-pattern — the exact shape Rector mis-transformed (which is why RadioService
was reverted from the Rector pass). Rebuild it safely, test-first:
Extracted 7 helpers, each covered by tests (RadioServiceTest, 18 tests):
- buildAutoRestart() — auto_restart schedule from days/time
- resolveSelectedIds() — created-name or numeric id (merged the duplicated
bouquet/category resolution)
- createMissingBouquets() — insert bouquets from bouquet_create_list
- createMissingCategories() — insert radio categories from category_create_list
- saveStreamOptions() — clear + re-insert streams_options (6 option types)
- syncServerTree() — reconcile streams_servers against the server tree
- syncBouquets() — attach to selected bouquets, detach on edit
Flattened the orchestrator: 250 -> 87 lines, nesting ~10 -> 4. Guard clauses for
validate / auth / no-source, ternaries for the flag fields, and the vestigial
single-element $rImportStreams loop removed (mutating $rArray directly is
equivalent to its per-iteration merge). exit() on auth failure is unchanged
(returning instead would be a semantic change). Behaviour preserved throughout.
RadioServiceProcessTest characterizes the outer guard flow (INVALID_INPUT, and
NO_SOURCES via the edit path) — the branches the flattening restructures. The
success path can't run under the SQLite harness (verifyPostTable hits MySQL
information_schema) so its internals are covered by the extracted-helper tests.
PHPStan level 5 clean; suite 711 tests / 0 errors (+20).
Ran `make rector-fix` (deadCode + codeQuality prepared sets) over the PSR-4
class trees (Core/Domain/Cli/Infrastructure). Net -600 lines: dead-code removal
and the empty-if/else collapse (the feedback_simplify_empty_else pattern this
adoption targeted). 99 files mechanically transformed.
The output was reviewed against the full test suite + PHPStan + a targeted scan;
this commit is the CORRECTED pass (no broken state in history):
- FIX (Rector bug): its boolean inversion dropped the parens around an
assignment-in-condition — `if (($rKey = array_search(...)) === false)` became
`if ($rKey = array_search(...) !== false)`, assigning the bool to $rKey and
unsetting the wrong array offset. Restored parens in 7 sites (BouquetService,
ChannelService, CategoryService). PHPStan caught only 1 of the 7; the rest
were silent. Added BouquetServiceTest as a regression (proven to fail on the
broken form).
- FIX (pre-existing, same class): GroupService::removeGroupFromUsers had the
identical dropped-parens bug already in the tree — corrected here too.
- FIX: ServersCronJob::pingServer returns floor() (float) under an `: int`
return type — added an (int) cast.
- ACCEPT: LocallyCalledStaticMethodToNonStaticRector converted 25 locally-called
private static helpers to instance methods (behaviour-preserving; call sites
rewritten). Updated MonitorCommandTest's reflection helper to invoke on a
constructor-less instance.
Verified: PHPStan level 5 clean (0), suite 691 tests / 0 errors.
NOTE: do not re-run `make rector-fix` on this tree without first skipping the
inversion rule — the dropped-parens bug is deterministic and would return.
Admin-API boot runs ResellerAPI::init() on the login page (pre-auth), where the
$rPermissions global is still null. It passes that null straight into
ServerRepository::getStreamingSimple()/getProxySimple(), whose strict
`array $rPermissions` hint turned it into a fatal TypeError at boot:
ServerRepository::getStreamingSimple(): Argument #1 ($rPermissions) must be of
type array, null given, called in .../ResellerAPI.php on line 72
Both methods only read $rPermissions via isset($rPermissions['is_reseller']), so
null is functionally equivalent to "no reseller restriction". Make the param
`?array $rPermissions = null` on both (restoring pre-typing tolerance) rather
than patching all ~28 call sites that feed the global. The strict `array` type
stays on every other repository method that runs post-auth with a real array.
Add ServerRepositorySimpleTest as a regression (null perms, online filter,
reseller name masking, proxy list) against the SQLite TestDb.
The panel boots the DB with `new DatabaseHandler()` and no arguments, so $host
is null and the real credentials are resolved by the bundled XC_VM extension in
db_connect() — dbhost is never used on that path. A strict
`normalizeHost(string $rHost)` hint turned that normal null into a fatal
TypeError at bootstrap (initDatabase → __construct → normalizeHost), taking down
console.php / cron boot:
Database::normalizeHost(): Argument #1 ($rHost) must be of type string,
null given, called in .../Database.php on line 53
Make normalizeHost accept and pass through null (?string → ?string), restoring
the pre-typing behaviour; the explicit-credentials path (db_explicit_connect)
still passes a real string. Also make the constructor's implicitly-nullable
`string $x = null` params explicitly `?string` (same root cause, no behaviour
change, and avoids the PHP 8.4 implicit-nullable deprecation).
Add DatabaseHostTest as a regression (reflection on a constructor-less instance,
since the class needs the XC_VM extension to instantiate).
Fifth coverage batch, all driven against the in-memory SQLite TestDb:
- ModuleMigratorTest: install runs master database.sql (or falls back to
replaying deltas <= target when there is none); up() applies only the
(from, to] range ascending; uninstall runs database_drop.sql or no-ops; has()
and discover() semver rules (non-semver files ignored, comments stripped);
and failure propagation from a bad statement.
- SettingsRepositoryTest: getAll() JSON/CSV field normalisation (allow_countries,
allowed_stb_types lowercase/trim/blank-drop, bouquet_name spaces, api_ips,
shared_mount_prefixes incl. the legacy-CSV self-heal) and empty-list collapse.
Points CACHE_TMP_PATH at a temp dir for the trailing FileCache write.
- AuthRepositoryTest: access-code and HMAC-key reads (getAllCodes with/without
type filter, getCodeById, getCurrentCode via XC_CODE, getAllHMAC, getHMACById)
and deleteHMAC existing/missing. getGroupPermissions (JSON_CONTAINS) and
deleteCode (nginx config regen) are left for integration.
+20 tests. Suite: 678 tests, 0 errors.
Fourth coverage batch — file-backed cache and i18n, each driven against a
throwaway temp directory:
- FileCacheTest: set/get roundtrip through serialization, miss = false, has(),
idempotent delete, flush, maxAge expiry (backdated mtime), and the path/age
accessors.
- TranslatorTest: English default, cookie language detection with fallback,
available() listing, {token} substitution, guarded setLanguage(), and the
hardened backfill — a key missing from the active language is written back
with the English value, or with itself as a visible placeholder when English
has none either.
+15 tests. Suite: 661 tests, 0 errors.
Third coverage batch — the pure/near-pure cores of the auth layer:
- AuthorizationTest: hasResellerPermissions() flag lookup; check() short-circuit
when identity globals are absent; the 'user'/'line' report-tree scoping via a
real SQLite query (owner/member within self + all_reports); and the 'adv'
permission logic (admin required, super-admin group 1 bypasses the advanced
list, other groups gated by it).
- BruteforceGuardTest: truncateAttempts() drops entries older than the window in
both indexed (reindexed) and associative (keys preserved) shapes; all-recent
and empty inputs. The IO-bound check* methods are left for integration tests.
+12 tests. Suite: 646 tests, 0 errors.
Second coverage batch — config, request state and the XML parser:
- SettingsManagerTest: set/getAll roundtrip, update, and the typed getters.
Locks the deliberate split on a present-null value (get()/getArray() fall back
via ?? while getBool/getInt/getString cast), and getBool()'s PHP truthiness.
- DomainResolverTest: the non-proxied resolve() path — forced vs kept protocol,
https on :443, Host-header port stripping, and the domain_name / server_ip
fallbacks. Captures a latent bug: an https:// prefix in domain_name yields
'https:host' because '/' is stripped before 'https://' in the str_replace
order (documented in the test so a fix updates it deliberately).
- RequestManagerTest: same store shape as SettingsManager, with has() on isset
semantics (present-null reads as absent).
- XmlStringStreamerTest: end-to-end StringWalker (depth-2 children) and
UniqueNode (named element) parsing over an in-memory stream, empty document,
the File "missing path" and UniqueNode "missing option" error paths.
+26 tests. Suite: 634 tests, 0 errors.
Request had no tests despite being the front door for all HTTP input. Built
from injected arrays (no superglobals), the new suite locks:
- GET/POST merge precedence (POST wins), input/get/post/has/all accessors and
their defaults.
- Typed accessors: getInt() coercion, getBool() via FILTER_VALIDATE_BOOLEAN.
- Server-derived helpers: method/isPost/isAjax/uri/userAgent/host (with the
HTTP_HOST -> SERVER_NAME fallback) and their empty-server defaults.
- Client IP resolution: first valid X-Forwarded-For entry, skipping invalid
headers to X-Real-IP, and the 0.0.0.0 sentinel when nothing is usable.
- Security sanitizers (the important part): NUL-byte stripping, ../ traversal
neutralisation, <script>/<!-- --> defusing, stripslashes, CRLF normalisation,
key scrubbing (__x__ / double-dot / htmlspecialchars), and recursive in-place
cleanGlobals().
+13 tests, 57 assertions. Suite: 608 tests, 0 errors.
Two regressions surfaced when running the suite (9 + 3 errors):
- setDb(DatabaseHandler) rejects the SQLite double: the DatabaseAware
refactor gave setDb() a strict DatabaseHandler hint, but TestDb was a
standalone class. Make TestDb extend DatabaseHandler (a real subtype) so
it satisfies the seam; its own constructor wires sqlite::memory: and never
calls the MySQL-connecting parent constructor. Overridden methods widen
parameter types (contravariant) and add return types, so LSP holds.
- validateHMAC(int|string $rExpiry) rejects null: HmacTokenTest passed null
for an intentionally-empty expiry. Pass '' — identical HMAC input, and the
strict type stays correct (the sole production caller always passes a value).
Suite is green again: 580 tests, 0 errors.
Exercised code paths echo diagnostics (dropOrphans() dropped-viewer lines,
GeoLite2 "[ERROR]" messages, ModuleLoader/GitHubReleases error_log output)
that clutter PHPUnit output without any test asserting on them:
- FanoutSyncOrphanTest / GeoLiteReleaseUpdaterTest: wrap each test in
ob_start()/ob_end_clean() via setUp()/tearDown().
- tests/bootstrap.php: route error_log() to /dev/null.
No production behaviour changes.
Mechanical, behaviour-preserving reformat produced by 'make cs-fix' under
the new build/phpcs.xml.dist ruleset: K&R braces, tab indentation, and the
other whitespace normalisations. No logic changes.
The settings page ran every field in its numeric list through
value.replace(/[^0-9]/g, '') on input, and fanout_idle_buffer_ratio was in that
list. It is a fraction (0.1-1): typing 0.25 left 025, and touching the stored
0.50 left 050. A decimal(3,2) column refuses 25 under strict mode, so the save
failed, and where it was clamped the daemon got a ratio of 1 — the whole buffer
kept while idle, the opposite of what was asked.
The field now has its own filter: digits and one decimal point, a comma taken
as one (a pt/ru/de keyboard types 0,25), and inputmode=decimal. On save,
SettingsService normalises it the same way, rounds it to two decimals and keeps
it in the daemon's 0.1-1 range; a value that is not a number leaves the stored
one unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbYsGKhirq9eRK8e6wsCHR
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 panel wrote prebuffer_max_sec = max(40, client_prebuffer,
restreamer_prebuffer, hls_window x seg_time). The ring is the daemon's
memory, and the flat 40 kept every watched channel at 40 s however far the
operator lowered Client Prebuffer or the HLS window: no panel setting could
shrink it, and a hand edit of config.json is overwritten by the next sync.
The ring now covers the HLS window and every prebuffer a viewer can ask for
(client, restreamer, the daemon default), each with one segment of headroom
on top — a join asking for as much as the ring holds starts in the block the
next keyframe prunes, and a viewer slower than one GOP there is dropped. It is
never under two segments, and still capped at the daemon's 120. At the
defaults (30 s prebuffer, 6 x 6 s HLS) that is 36 s instead of 40; with a
10 s prebuffer and a 3-segment window it is 18 s.
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
The internal API (LB -> main), the admin live/vod/timeshift proxies, the
admin API and RTMP publish/play checked their shared secrets —
live_streaming_pass, api_pass, the RTMP allow-list passwords — with ==.
That compares two numeric-looking strings as numbers ("1000" == "1e3") and
stops at the first differing byte, which a patient client can time.
They now go through AuthService::secretMatches(): hash_equals on strings,
false for anything a query string can make that is not one (null, an array),
and false for a secret that is not configured. Each caller keeps its own
"no secret required" rule (an empty api_pass, an allow-list entry with no
password), exactly as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuZvjFSdodqgpyXtaoH1Xt
It caught only \Exception, so a PHP Error thrown by the callback (a
TypeError, an undefined method) skipped the rollback: the transaction stayed
open on the connection until it closed, and the handler kept believing it
was inside one — which also switches off its reconnect-on-failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuZvjFSdodqgpyXtaoH1Xt
A new monitor takes a few hundred milliseconds to write its _.monitor file.
Every viewer that reached a stopped on-demand stream inside that window found
it unwatched and started it again: the later viewer deleted the _.monitor and
_.pid the earlier monitor had just written, each monitor's checkRunning ran
before the other had set its process title, and both launched a producer —
two connections to a source that often allows one, kicking each other off.
The check-and-start in live.php now runs under a per-stream flock
(<id>_.start on the streams tmpfs). The viewers behind the first wait on it,
re-read the monitor pid, and find the stream started. A viewer that dies
releases it with its process. The test runs four simultaneous viewers in
separate processes: one start with the lock, four without.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuZvjFSdodqgpyXtaoH1Xt
validateHMAC accepted a link when md5($genuine) == md5($given). PHP's loose
== reads two digests of the form 0e<digits> as the number 0 and so as equal:
for any request whose genuine HMAC has such an MD5 (about one in 3·10^8,
over parameters the requester chooses — identifier, expiry, max), a given
`hmac` like 240610708 passed as the key, and the stream was served under
that key's connection limits. The regression test carries a concrete case
found by search.
The HMAC is now compared with hash_equals against the given value itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuZvjFSdodqgpyXtaoH1Xt
A successful admin or reseller login wrote the signed-in user into whatever
session the visitor arrived with; nothing in the panel ever called
session_regenerate_id. With session.use_strict_mode off, PHP adopts any id a
client presents, so an id planted in an admin's browser beforehand (a cookie
set from a sibling subdomain, a shared machine) became a signed-in admin
session the moment they logged in — session fixation.
Login (admin and reseller) and the first-run setup page now move the session
onto a fresh id and discard the old one. The admin session also starts with
use_strict_mode on, so ids this server never issued are refused, and with the
cookie HttpOnly: no panel script reads it, and an XSS should not be able to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuZvjFSdodqgpyXtaoH1Xt
activateCode checked the device lock only when the request carried a MAC, so
any client could read a locked code's line credentials by leaving `mac` out —
through /api/active_code, through player_api (which accepts a code as the
username with any password) and through the portal. A code bound to a device
now answers that device only; a request naming no device is refused like any
other mismatch. Admins and resellers clear a binding with "reset device".
The first activation is also claimed atomically: the UPDATE repeats the
"still unactivated" check in its WHERE, so of two requests that read a fresh
code at once only one binds its device and starts the countdown. The other
re-reads the code and meets the lock, instead of re-binding the code to itself
and restarting the subscription's expiry. is_new_activation now reports that
this call did the activation, not that one happened in the last 5 s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuZvjFSdodqgpyXtaoH1Xt
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 FPS check multiplied the current rate by the threshold percentage
without dividing by 100 (fps * 90 < baseline), so it only fired below
~1% of the baseline — never. It now restarts below fps_threshold% of
the baseline (90 when unset, as the fanout supervisor does).
- Priority backup probed every other source, including lower-ranked ones:
a stream on backup B moved down to C while the primary was still out.
Only sources ranked above the current one are considered.
- An on-demand start whose source cannot be probed now honours
on_demand_failure_exit instead of re-probing until the cron stops it.
- A stale force-source signal naming a missing index is ignored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Timeshift TS seeking returned the wrong bytes: the start file was
estimated from the average file size, files before it were never skipped
(an empty `if` where a `continue` belonged) and the in-file offset came
out negative, so every catch-up seek streamed from the archive's first
byte; the range end was ignored too. The served range is now mapped
exactly onto the minute files (first file from its .offset).
- The timeshift throttle never reset its chunk counter (vod.php's copy
did), so past vod_limit_perc every chunk paused a whole second.
- A shared HttpRange parser (RFC 7233 single ranges) replaces the inline
copies: suffix ranges (bytes=-N) died on PHP 8 arithmetic, an
unsatisfiable range answered with the resource's range instead of
`bytes */size`, and Accept-Ranges said "0-<len>" instead of "bytes".
VOD never reads past a bounded range's end.
- Direct-proxy VOD reads the source's headers with cURL: get_headers()
goes through the https stream wrapper, which does not work under
PHP-FPM here, and returned Content-Length as an array after a redirect.
The upstream is asked for exactly the requested range, and curl's
verbose output no longer goes to the FPM log on every request.
- The limiter spares the requesting connection by uuid (VOD/timeshift).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HlsSequence re-applied the off-air wall-clock floor (time()/10) on every
publish. The daemon's segment length follows seg_time (1-30 s) and keyframe
cuts stretch it further, so for segments over 10 s the daemon counter fell
behind the floor and each catch-up shifted every listed segment by one —
players replayed or skipped a segment several times a minute.
The floor now applies only where a player can have seen the off-air loop:
the first publish, a daemon counter that went backwards, a publish gap
longer than max(30 s, 3 target durations), or an explicit markOffAir(),
which OffAirHandler calls when it serves the off-air playlist for a known
stream. The target duration is read from the daemon playlist. The state
moved from STREAMS_PATH, where a stream restart's `rm -f <id>_*` erased it
(letting the next publish step back under what a connected player had
seen), to tmp/signals.
Also removes the dead HLSGenerator::generateHLS() (no callers since
Phase E) and fixes tokenizeDaemonPlaylist's stale "unencrypted only" doc.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A daemon-served TS viewer's row is written with pid 0 — the PHP worker that
admitted it returns at the X-Accel hand-off. closeConnection() only killed
a pid above 0 and then deleted the row, so a limit eviction or an admin
kick left the viewer streaming from the daemon, untracked: a line limited
to one connection could hold any number of TS streams.
- FanoutClient::dropConnection() calls the daemon's new
DELETE /connections/<uuid> (XC_VM_Fanout, feature "drop_connection").
- ConnectionTracker::dropDaemonViewer() drops a pid-0 viewer directly, or
sends a drop_con signal to the viewer's node (DB and Redis signal paths,
handled by SignalsCommand). Both closeConnection() implementations use it.
- The limiter spares the requesting connection by uuid: every daemon row
shares pid 0, so the old "not my pid" check let a new viewer evict
itself. HMAC identities in Redis mode are looked up under their
"<hmac>_<identifier>" key (a null line id hit an int-typed parameter).
- A kicked local HLS viewer loses its segment marker at once, and in Redis
mode a closed HLS connection is no longer silently reopened by the next
playlist request (the MySQL path already required hls_end = 0). A
re-auth that reuses the player's deterministic HLS uuid leaves ENDED /
replaces the closed row, so the reaper cannot delete the new connection.
- fanout_sync also reconciles the other way: a daemon viewer whose row is
gone past a 20 s grace is dropped. It is skipped when the rows could not
be read, so a Redis/DB blip cannot disconnect everyone.
- Redis signal keys include the payload, so pid-less signals do not
overwrite each other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Client delivery is daemon-only since ADR 0003 Phase E, but several
producers never fed the daemon, or fed it in a way no player could use:
- Delayed channels (delay_minutes > 0) were unwatchable: the encoder's tee
is skipped for them (its output is the undelayed stream), the supervisor
refuses them, and DelayCommand never registered an ingest, so every
viewer got not-on-air. DelayCommand now pushes each delayed segment into
the daemon as it publishes it (IngestFeeder), paced over the segment's
duration, with a two-segment burst at start. Its loop polls every 50 ms
instead of hashing the playlist every 1 ms.
- An ffmpeg loopback (php_loopback off, or no supervision) registered no
ingest at all; startStream now tees loopback streams too.
- Encrypted HLS was undecodable for loopback and llod=2: the playlist
declares AES-128 whenever encrypt_hls is on, but those producers
registered without the key, so the daemon served plain segments. Every
producer now passes the key; startLLOD/startLoopback write it before
spawning the child that registers.
- LlodCommand/LoopbackCommand feed through IngestFeeder (no torn packets,
reconnect after a daemon restart). LLOD also honours the stream's
headers/cookie/proxy/default user agent and request_prebuffer, accepts
TS sources by content when the Content-Type is not video/mp2t, kills a
stale segmenter by process title (it read the MONITOR's pid), and writes
its playlist atomically (the loopback relay too).
- LLOD ffmpeg: +nobuffer moves to the input where it has an effect;
-tune zerolatency only for x264/x265 (NVENC rejects it and failed the
start; it gets -zerolatency 1); an LLOD start uses the first source
instead of falling through to the last.
- Remote VOD subtitles URL-encoded the shell-quoted path.
- cron:streams: the restreamer "attached" subqueries lacked GROUP BY, so
on-demand parents could be stopped under a child LB; a stopped on-demand
stream went on to spawn thumbnail/archive workers; the daemon re-feed
restart now skips self-feeding producers (PHP relays, delay) and its
throttle stamp moved out of STREAMS_PATH, where the restart's
`rm -f <id>_*` deleted it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Since ADR 0003 Phase E the xc_fanout daemon is the only client delivery
path, so for the PHP producers (the LLOD segmenter, the loopback relay and,
next, the delay worker) the feed into its ingest socket IS the channel's
delivery. They wrote it with a bare non-blocking fwrite(): a short write
silently dropped the rest of the buffer, tearing TS packets, and after one
failure the feed stopped for the life of the process.
IngestFeeder keeps what the socket did not take and sends it first next
time (capped, shedding the oldest whole packets), discards a half-sent
packet's tail on reconnect so the new connection starts aligned,
re-registers and redials after a daemon restart with a backoff, and
carries the stream's HLS key/iv for encrypted HLS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
With fanout_supervise on, StreamProcess::startMonitor() no longer spawns a
PHP watchdog. It builds the stream's commands and hands them to the
daemon's supervisor (PUT /monitor/<id>), which starts, watches and
restarts them: failover, priority backup, forced source, stalled output,
audio loss, frame-rate drop and scheduled restart. PHP still builds every
command and makes every database write.
A copy-only live stream's command is the daemon's native remuxer,
`xc_fanout remux`, composed by the new buildNativeLive() exactly as
buildLive() composes an ffmpeg line. It reads the source natively and
writes the same on-disk HLS and daemon feed as the ffmpeg -f tee output,
with no ffmpeg process. fanout_source_backend decides: auto = remuxer with
the ffmpeg command as fallback_cmd (taken when the remuxer exits 3,
"cannot serve this source": fMP4 or encrypted HLS, rtmp, no keyframes),
native = remuxer only, ffmpeg = ffmpeg only. Eligibility is explicit
(isNativeEligible / isNativeSource): no transcode, custom ffmpeg, custom
map, RTMP output, external push, timestamp repair, read-native or forced
input codec; http(s)/udp/rtp sources only.
The rest of the panel learns who owns the producer:
- superviseStream() asks the daemon first and touches nothing unless it
is accepting; without a restart it adopts a running encoder, so
cron:streams moves PHP-monitored streams over with no blip. A producer
the daemon cannot adopt (PHP LLOD, PHP loopback) is replaced, never left
beside the new one. The row is marked watched before the hand-over, so
the reconcile cannot release a stream mid-start.
- reconcileSupervised() copies the daemon's state (status, pid, source,
codecs, resolution, measured bitrate) into streams_servers: every
cron:streams pass and every 5 s from the signals daemon. Supervised
streams whose row is gone or stopped are released.
- stopStream() and the on-demand reaper release before killing anything;
killing the producer first is what the supervisor restarts.
- isWatched() replaces bare isMonitorAlive() checks in live.php,
admin/live.php, rtmp.php and cron:streams: a supervised stream's
monitor_pid is the daemon's. MonitorCommand stands down for supervised
streams; startMonitor() releases one before falling back to PHP, so
turning supervision off does bring streams back on their next restart.
- force_stream goes through the daemon for a supervised stream (the .force
file is only read by the PHP monitor).
- ProcessManager::isStreamRunning() recognises the remuxer, so the
archive, thumbnail and delay workers follow it like ffmpeg.
- A supervised loopback child tees into the daemon (the supervisor judges
a stream by the bytes it receives); legacy loopback is unchanged.
Delay streams, created channels and yt-dlp platform sources stay on the
PHP monitor. A daemon without /monitors/state (older than this) is never
handed a stream, so the panel is safe against an un-upgraded node.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012QL93N6dzGkmKoQgA4oh16
Whether live streams on each server are handed to the xc_fanout daemon's
encoder supervisor instead of getting a per-stream PHP watchdog
(console.php monitor). On by default: the supervisor does everything the
PHP monitor did, from one process per node instead of one per channel,
and the PHP monitor stays the fallback for a daemon that cannot be
reached.
Touches the places every fanout_* setting lives: migration 018 for
upgrades, database.sql for fresh installs (277/277 columns and values
still aligned), the FanoutConfig mapping that writes `supervise` into the
daemon's config.json (applied live by the daemon, no restart), the
settings UI, the checkbox whitelist in SettingsService, all seven language
files, and the unit test. The source-backend tooltip now says what the
backend means for the panel's own streams as well.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012QL93N6dzGkmKoQgA4oh16