Commit Graph
96 Commits
Author SHA1 Message Date
obscuremind 4566ecbd52 feat(cluster): report settings reads the replica would not answer
Plan section 9, R1 `settings`: API nodes log unknown-key reads to
`audit.settings_misses`. Once a node's replica owns its settings, a key
outside lb_settings_keys.php is absent there, and the CI scan behind the
allowlist cannot see a key built at run time. The count names such reads
before they fail.

On the node (Core/Cluster/SettingsAudit): in mode 1 or 2, as the agent's
flows.json says (NodeFlows::declared, no database and no servers read),
each read through SettingsManager's getters (get, getBool, getInt,
getString, getArray, has) of a key outside the allowlist's `keys` and
`withheld` is counted. A process keeps its counts in memory and merges
them at exit, and every minute while it runs, into a per-day file under
its lock (STORAGE_PATH/cluster/settings_misses/YYYYMMDD.json). It then
writes config/cluster/audit.json, `{"settings_misses": {key: count}}`
over the last seven UTC days, which the agent is to send as the
heartbeat's `audit`. Everything is bounded: 64 names, the rest and any
key that is not a settings name under "*". cron:cleanup prunes the day
files after eight days and rewrites audit.json every hour, so old days
drop out; in mode 0 the file is removed. MAIN and legacy nodes have no
flows.json and count nothing; a read costs one lookup.

On MAIN (Domain/Cluster/NodeAudit): a heartbeat's `audit` is normalised
(names and positive integer counts only, at most 16 KiB, the least
missed past 64 names folded into "*") and kept in the new
cluster_nodes.audit (migration 045, mirrored in database.sql; 044 is taken
by work on another branch). It is written only when it differs from what
the row holds, which every request reads already, so an unchanged report
costs no query, on the cluster bus or not. A heartbeat without `audit`,
today's agent's, changes nothing. The Cluster Nodes page shows each mode 1
or 2 node's misses.

SettingsAuditTest covers the counting per getter, withheld keys not
counted, mode 0 and MAIN counting nothing, the merge across processes,
the caps, the seven-day window, the pruning, the empty report and the
minute's merge of a long-running process. ClusterApiTest covers the
heartbeat storing a report once, an unchanged, missing or malformed one
writing nothing, an empty one clearing the page, and the cap; and
ReplicaBootTest a child process's miss reaching audit.json at its exit,
while booting from the replica reads only allowlisted settings.
2026-09-27 02:26:55 +00:00
obscuremind c153967935 fix(cluster): stop the listen-queue guard marking its own nodes offline
Review of the cluster_ctl listen-queue guard.

- The pass that saw the queue end judged nodes by heard times read before
  it, and the queue's time counted as node silence. A lone TELEMETRY node
  silenced only by the queue was therefore marked offline at the drain.
  The queue reason now keeps a hold in health.json (`ctl_queue_hold:
  {from, until}`). When the reason clears, whether drained or unknown, the
  hold lasts one more cluster_offline_after_sec, so each node gets a full
  offline window after the queue, as ready_at gives it after MAIN's own
  downtime. The pass also reads the probe before HeartbeatService::flush().
- The hold spares only the nodes the queue may have silenced: those whose
  silence counts from the run's start minus 10 s or later. It lasts at
  most 4 offline windows from the run's start (QUEUE_HOLD_WINDOWS), so a
  chronic mild queue no longer keeps a dead node suspect for as long as
  it lasts.
- A pass now joins a run whose `at` is within QUEUE_GAP_MS on either side.
  Before, cron:cluster broke the daemon's run when the daemon wrote
  health.json between cron's clock read and its read of the file. A
  joined run keeps the later `at`. A clock stepped back further still
  starts a new run.
- Tests: a lone node silenced by the queue is never marked offline (drained
  and unknown, read back from health.json); nodes silent before the queue
  are not held; ready_at counts; the cap, with and without a drain near
  it; the join edges (a few ms behind, exactly 5 s, a clock step back);
  the run persisted through health.json; the probe read before the bus
  flush; the Cluster Nodes page's alert per reason. ClusterPoolTest adds a
  late answer that keeps the queue's start and a drain that forgets it, a
  probe dropped for another pool, and an answer past MAX_ANSWER (responder
  modes sleep:<ms> and huge). The tests that tick liveness without faking
  the probe now fake one, so none probes a socket under MAIN_HOME.
- ADR 0004 and the plan describe the hold, its limits and its tests. The
  wire is unchanged, so the agent's contract stays empty.
2026-09-26 22:20:28 +00:00
obscuremind c0a7ce2434 feat(cluster): raise the fleet guard on a cluster_ctl listen queue
Plan section 8: a cluster_ctl pool listen queue lasting over 5 s triggers
the same guard as the fleet silence. That pool takes every heartbeat, so a
queue there makes live nodes look silent.

LivenessService::tick() reads ClusterPool::listenQueueMs('cluster_ctl')
once a pass (the reader is injectable for tests; the clock is
ClusterClock). A run lasts from now minus the age the probe reports, and
the guard's `ctl_queue` reason is up while it is over 5 s old. A pass that
sees no queue, or cannot tell (pool down, no status page, a reader that
throws), ends the run, so a missing pool never raises the guard. A pass
within 5 s of the last one that saw the run joins it, so cron:cluster's
own short-lived request does not restart the signals daemon's run; the
run is kept in health.json (`ctl_queue: {since, at}`) for both.

ClusterHealth keeps `guard` (what routing and the orphan purge read) plus
its `reasons` (silence, ctl_queue); a file without reasons reads as the
silence. Either reason holds nodes instead of marking them offline and
suspends the orphan purge. Each reason is audited on its own:
cluster.fleet_silence(_clear) as before, now per reason, and
cluster.ctl_queue {queued_ms} / cluster.ctl_queue_clear {lasted_ms,
queue: drained|unknown}. The Cluster Nodes page shows one alert per
reason, with a new en.ini string for the queue.

Tests: a 5 s queue raises nothing; over 5 s raises the guard with its
reason and holds a lone silent node until the queue drains; a pool that
cannot tell never raises it; both reasons together, each clearing on its
own; another pass joining a run, and a gap starting a new one; a legacy
health.json.
2026-09-26 22:20:28 +00:00
Claude 4b7f254bae fix(cluster): refuse a cluster_api_port nginx cannot take, and test the save path
Review of the rendered nginx config (ee6923c0, 968acbb5, 4ef12211).

nginx -t passes when another program holds a port (in test mode nginx
skips EADDRINUSE), and nginx -s reload exits 0 once the signal is sent.
The master then keeps its previous config and the next start fails with
"still could not bind()", taking MAIN down, while the port was stored
and announced. Reproduced with nginx 1.24.

- ClusterNginxConfig::apply() checks that a new dedicated port (one
  nginx does not listen on by its files) can be bound before writing
  anything. After the reload it waits up to 3 s for the port to accept
  connections, and otherwise puts the previous files back and reloads
  again. It refuses a render that needs cluster.d/ when nginx.conf
  predates it (an update rolled nginx.conf back). Called without
  settings, it reads cluster_api_port and cluster_legacy_ports from the
  database. A refusal that repeats the last one is audited once.
- The save path moves into stageApiPort() and commitApiPort(). A port
  another program holds is refused with cluster_error_port_busy (en.ini).
  After the UPDATE, the policy is bumped only if the value was stored.
  nginx is then rendered again from what is stored, so a render that
  raced the save is undone and a failed UPDATE puts nginx back.
- cron:cluster renders on every pass, with the API on or off. That
  retries a failed render and releases ports kept before the API was
  switched off.
- cluster:nginx drops its own user check; apply() already refuses any
  user but xc_vm.
- Tests: the save path (new port, kept port, nginx refusal, busy port,
  stored and not stored), SettingsService::edit() refusing a save on
  TestDb, the reload rollback, the write rollback, the old nginx.conf,
  audit dedup, cron:cluster, cluster:nginx, and the order of the
  set_port renders. A mutation run on the reviewed paths kills all 17
  mutants.
- ADR 0004 and the plan: the corrected mechanism, and what the port
  checks cover. HTTPS port and IP changes are recorded as not announced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
2026-09-26 15:42:15 +00:00
Claude 152074aad9 feat(cluster): move nodes off a changed cluster_api_port after nginx -t
ClusterEndpoint announced and kept only MAIN's old broadcast port, and a
new cluster_api_port reached nginx nowhere. Plan §3 applies the port
only after nginx -t passes, and keeps the old one (cluster.d/
old_port.conf) while the nodes move.

- ClusterEndpoint::afterApiPortChange() gives the old ports to keep when
  cluster_api_port changes. The API's URL is the broadcast port while
  the setting is 0, so 0 -> N keeps the broadcast port listed and N -> 0
  keeps N. Nothing is announced when the URL stays or the API is off.
  recordApiPortChange() stores it and bumps cluster_policy_ver, as
  recordChange() does for the broadcast port.
- A settings save that changes cluster_api_port renders nginx with the
  new port and the kept one before storing the value. When nginx -t
  fails, the save is refused with nginx's message (cluster_error_nginx,
  en.ini only) and nothing changes. The policy is bumped only after the
  value is stored, so no node is sent a URL nginx does not serve.
- ClusterPolicy lists kept ports whatever cluster_api_port is, since
  they are served either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
2026-09-26 15:42:06 +00:00
DanilandGitHub e26b4cd936 Merge pull request #209 from Vateron-Media/feat/dev-channel
Feat/dev channel
2026-09-26 17:04:41 +03:00
Divarion_D e865611205 feat(updates): add a dev update channel for nightly panel builds
The panel update channel gains 'dev' (BIN and FANOUT stay stable/beta).
On 'dev' the MAIN release client also reads the releases-only XC_VM_Dev
repository, merges both release lists by version_compare(), and fetches
each asset from the repository that published its tag (X.Y.Z-dev.N tags
live in XC_VM_Dev). An unreachable XC_VM_Dev leaves the channel on the
regular releases. Nightly changelogs are read from a release asset,
since XC_VM_Dev has no source tree.

- UpdateChannels::mainReleases() builds the MAIN client; the update
  command, update cron, web API bootstrap, rollback picker and server
  install use it instead of constructing their own.
- The releases API is requested with per_page=100 so prereleases cannot
  push stable releases off the default 30-item page.
- Nightly builds are excluded from the rollback picker (rollback accepts
  X.Y.Z only).
- Settings: 'Dev (nightly)' option for the panel channel plus a warning.
2026-09-25 22:40:51 +03:00
obscuremindandGitHub e1a27335af Merge pull request #34 from Vateron-Media/main
Up
2026-09-25 20:09:54 +01:00
Claude 7342705f72 feat(cluster): opt-in firewall allowlist for MAIN's MariaDB and Redis
Until lockdown, 3306 and 6379 listen on every interface. With the new
`cluster_db_allowlist` setting (off by default, admin Cluster tab) only
loopback, MAIN's own IPs, every LB and proxy row except nodes already in
cluster mode 2, and `cluster_db_allowlist_extra` (IPs/CIDRs) may connect.

- DbAllowlist keeps the rules in an XCVM_DB chain jumped to from INPUT for
  the two ports, v4 and v6, and touches nothing else. Commands run with
  literal argv through proc_open (no shell).
- The root signals cron reconciles it every minute on MAIN: the live chain
  is compared with the wanted one and rewritten only when they differ, so
  a flush, a reboot or a server change is corrected within a minute, and
  turning the setting off removes the chain. It never acts on a guess:
  unreadable settings or no MAIN row leave the firewall as it is.
- `cluster:db-allowlist status` lists who is allowed, whether the chain is
  in sync, and the established connections it would cut; `apply`/`undo`
  flip the setting and act at once. Changes are audited.
- getBlockedIPs() reads INPUT only, so the chain's DROP is not taken for a
  ban on 0.0.0.0/0.
- Migration 040 plus database.sql; i18n in all eight languages. The
  command is stripped from LB builds and guarded by the leak gate.

Tested with a fake firewall, and (XCVM_TEST_NETNS=1) against the real
iptables/ip6tables in a throwaway network namespace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
2026-09-25 19:06:41 +00:00
DanilandGitHub 418e7a7093 Merge pull request #206 from obscuremind/main
LBS Phase 6
2026-09-25 22:05:05 +03:00
Divarion_D dc64b80743 fix(i18n): repair a spliced warning_fingerprint string and duplicate keys
A Spanish `users = "Usuarios"` line had been spliced into the middle of
the English warning_fingerprint value ("significa...ntly") in en.ini, and
de.ini / fr.ini carried the same broken copy. en.ini also defined
select_at_least_one_server and shared_mount_prefixes_tooltip twice; drop
the earlier copies - PHP keeps the last one, so the panel text is
unchanged.
2026-09-25 21:52:31 +03:00
Divarion_D 67cbade86d feat(dashboard): turn Service Status into an always-visible checklist
The card used to list only failing probes, so a healthy panel showed a
single "no issues" line in an otherwise empty card. It now always lists
every check with an icon and state (ok / warn / fail / off), a short
status line, and the existing fix-it command under a failing row:

- Servers: enabled servers online, offline ones named (new)
- Database schema: status_uuid watermark vs XC_VM_VERSION
- Root cron jobs: age of config/signals.last (stale after 10 min)
- xc_fanout: per fresh watchdog report; "off" when disabled in settings

The MariaDB JSON_CONTAINS probe is dropped: the installer pins a
supported MariaDB, so it cannot fire. Checks are pure static methods on
DashboardController and covered by DashboardStatusChecksTest. Also fixes
the server name in the old fanout title being HTML-escaped twice.
2026-09-25 21:32:54 +03:00
Divarion_D 89de3fca7c feat(dashboard): merge stat tiles into a single Statistics card
Replace the six separate stat tiles with one Vuexy-style "Statistics"
card: icon badge, value and label per item, vertical separators between
items (6 / 3 / 2 per row by breakpoint) and a "Last updated" clock
refreshed on every stats poll.

The "/ total" secondary on Online Connections and Active Lines is only
shown with a server selected: it means "this server / whole fleet", so
in the fleet view it either duplicated the main value or read 0.
2026-09-25 21:32:53 +03:00
Claude 45a8246a7b feat(cluster): the agent holds a CONNECTIONS node's viewers (Phase 6, increment 3)
On a node whose CONNECTIONS flow is on, the connection store seam
(openRecord, findByUuid, lookupLive, updateLive, heartbeat, acceptedIP)
reads and writes the node's agent over the local socket
(Core\Cluster\AgentConnections, 1 s timeout) instead of MAIN's Redis or
lines_live: no WAN call on the stream endpoints' hot path. When the
agent does not answer, the call falls back to MAIN's store.

MAIN keeps its store current from the agent's P0 conn.upsert /
conn.remove events (Domain\Cluster\ConnectionIngest, Redis or
lines_live): server_id is the sender, the identity is recomputed, and a
uuid another node holds is refused. Closes decided on MAIN reach the
node's registry as a restrictive conn.close {uuid, remove} command; the
node's own closes still write MAIN's store and tell its registry.

ConnectionTracker::removeRecord is the Redis removal closeConnection
already did. The Cluster Nodes page switches CONNECTIONS, refusing it
(and turning off what it needs) against the flow dependencies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
2026-09-25 17:43:29 +00:00
Claude aacaa67abc feat(cluster): content as events, recordings through MAIN (Phase 5, increment 2)
ContentSink routes what a node reports about its own content: recording
status (CONTENT), archive/thumbnail worker pids (STREAMS) and movie
ffprobe results (CONTENT). Legacy nodes write MAIN's database as before;
nodes with the flow on spool P0 events, which EventIngest applies only
where the node is the owner (recordings.source_id, <worker>_server_id,
a movie it holds), merging analysis keys into MAIN's movie_properties.
MAIN writes the stream cache signal when an event changes routing
state; a STREAMS node no longer writes it into MAIN's database.

RecordingFinalizer turns a recording into exactly one VOD in two steps
around the node's conversion: create() (idempotent, returns the id that
names the file) and finish() (attach to the node, status 2). A CONTENT
node gets the id from the new `recording_complete` op through the
agent's local socket (Core\Cluster\AgentClient), then reports status 2
as an event. The Cluster Nodes page switches CONTENT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
2026-09-25 15:28:11 +00:00
Claude 5056a53b1b feat(cluster): logs and stream state as events (Phase 5, first increment)
On a node whose LOGS / STREAMS flow is on, LogSink and StreamStateWriter
spool redacted events for the agent (Core\Cluster\EventSpool, one file per
write under config/cluster/spool/<lane>/) instead of writing MAIN's
database; with the agent stopped (flows.json untouched for 120 s) they
fall back to SQL.

MAIN's new `events` op (EventIngest) applies a lane's batch and its
cursor together: P0 (stream.state) is gap-checked with a signed 409
USEQ_GAP {expected_useq}, P1 (log.<type>, skip) skips what was applied.
Events apply as the sending node only: stream state merges runtime
columns into its own streams_servers row, log rows get its server_id,
both redacted again; events of a flow that is off are dropped. hello
returns the cursors. The Cluster Nodes page switches LOGS and STREAMS.

NodeFlows now reads flows.json before asking NodeRole, so a node without
the file never touches the database to find out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
2026-09-25 15:15:21 +00:00
Claude 0960418307 feat(cluster): root commands over the command channel
NodeActions (reboot, update, service restarts) become signed node.root
commands for nodes with the COMMANDS flow whose root-owned pin of the
panel key is in place; others keep the signals table.

- RootPin: /etc/xc_vm/cluster/{main_sign.pub,node,root.seq}, trusted only
  when root-owned and not group/other writable
- cluster:exec hands node.root to root through config/cluster/root-inbox
- cluster:root (root cron, 58 s watch) verifies against the pin, raises
  its own high-water before running, runs RootSignalsCronJob actions and
  writes results without following planted links
- cluster:pin-root <panel_fp> pins a code-enrolled node; LbInstallFlow
  pins over SSH at install
- cluster_nodes.root_ready (migration 039) from the agent's heartbeat;
  the Cluster Nodes page shows it and the panel fingerprint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
2026-09-25 14:40:01 +00:00
Claude 4f0cf12713 feat(cluster): signed command channel to nodes (Phase 4, COMMANDS flow)
CommandBus queues typed, panel-signed (`cmd`) commands per node in
cluster_commands: FIFO by seq, dedupe_key for desired state, expiry,
pruned by cron:cluster. New ops: `commands` (long-poll after the node's
high-water, held up to 20 s; frozen while quarantined) and `ack` (own
commands only; raises cluster_nodes.cmd_seq).

For nodes with the COMMANDS flow on (toggled on Cluster Nodes),
ClusterRoute sends NodeRpc::request() as a signed node.rpc and waits for
its ack, and NodeRpc::broadcast() / SignalDispatcher::kill() queue
commands; other nodes and every LB keep the legacy transport.

On the node, `console.php cluster:exec` checks a command again against
the panel key the agent pinned and runs node.rpc through the legacy /api
handlers (InternalApiController::runCommand, NodeRpc::ACTIONS only) or
conn.kill_worker. Root actions stay on the signals table for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
2026-09-25 13:24:24 +00:00
Claude ad6d136fb7 feat(cluster): 1 s liveness loop for telemetry nodes, with the fleet silence guard (Phase 3)
LivenessService::tick() runs every second in MAIN's signals daemon
(cron:cluster is the minute fallback) and judges the active nodes whose
TELEMETRY flow is on by their silence: ok, suspect after 10 s, offline
after cluster_offline_after_sec (30 s). The result, in
tmp/cluster/health.json, is read through Core\Cluster\ClusterHealth:
ServerRepository::getAll() takes server_online from it for those nodes
(instead of the 90 s last_check_ago rule) and getCapacity() doubles a
suspect node's weight. Each transition rewrites the servers cache and is
audited.

When over half of the judged nodes (at least two) fall silent together,
the fleet silence guard holds every node at its last state instead of
marking any offline, audits it and alerts on the Cluster Nodes page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
2026-09-25 12:54:40 +00:00
Claude f162182a03 feat(cluster): authoritative telemetry from the agent (Phase 3, TELEMETRY flow)
For a node with the TELEMETRY flow on, MAIN writes the server's
watchdog_data, last_check_ago, requests_per_second, php_pids (and,
without the Redis handler, connections/users) from the agent's heartbeat
every 5 s, and its servers_stats row every minute.
HeartbeatService::toWatchdogData keeps the legacy getStats() shape,
pinned by ClusterTelemetryTest against getStats()'s source.

On the LB, Core\Cluster\NodeFlows reads the mode and flows the agent
writes to config/cluster/flows.json. With TELEMETRY on, the watchdog
stops writing its row and only refreshes local.json (nginx rps, fanout
status) for the agent; cron:servers skips its stats row and network.py
is stopped. The Cluster Nodes page toggles the flow per node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
2026-09-25 12:54:40 +00:00
Claude 775362f99e feat(cluster): Cluster Nodes admin page and cron:cluster housekeeping
Servers → Cluster Nodes (permission `servers`) is the admin side of the
cluster CLI, via Domain\Cluster\ClusterAdmin: enrolled nodes with state,
liveness, epoch, token expiry, last heartbeat and agent version; code
enrolments waiting for a SAS (approve / reject); issuing an enrolment
code (shown once); and revocation. The deciding admin is recorded.
Strings in all eight languages.

cron:cluster (enabled by migration 037) deletes expired epochs (erasing
their z), the replay cache and used challenges, unused expired codes and
decided requests after a day. It returns at once on load balancers and
while the API is disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
2026-09-25 12:39:13 +00:00
Claude 625273d83c feat(admin): cluster settings with server-side checks and a Cluster tab (Phase 1)
Core\Cluster\ClusterSettings owns the defaults and bounds of the 19
cluster settings. normalize() clamps numbers, falls back to the default
for an unknown enum, and refuses rather than clamps where clamping would
change what the admin meant:

- a cluster_api_port outside 1024–65535, or one MAIN already uses
  (broadcast, extra and RTMP ports) or that is reserved (31210, 31290,
  3306, 6379);
- a cluster_main_host that is not a DNS name;
- scan roots that are not absolute paths free of '..';
- https_required, unless MAIN's own HTTPS verifies (httpsSelfProbe: a
  non-IP domain and a certificate with more than 7 days left) and no
  active node blocks it (every active node blocks until Phase 3
  telemetry reports HTTPS);
- enabling the LB API without xcvm_core's cluster API
  (ClusterCryptoFactory);
- API mode for new nodes before the cutover phase.

SettingsService::edit() runs these checks. A refusal fails the whole
save, and its translated message is shown on the settings page. It lives
in Core/Cluster, which ships to LBs, so nodes apply the same bounds.

The settings page gets a Cluster tab after Streaming. It shows every
field, and a status line: the extension and its API range, whether HTTPS
is available, and the derived token grace. The strings come in all 8
languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
2026-09-25 10:28:53 +00:00
Claude 79088ff0ef feat(fanout): add a master switch that stops the xc_fanout daemon
settings.fanout_enabled (migration 027, default on) is a new "Fanout
Delivery" toggle beside the other fanout settings. Streaming\Fanout\FanoutMode
reads it and applies it to a node:

- Off: FanoutMode::applyToNode() writes bin/xc_fanout/disabled, then stops
  run.sh (so it cannot respawn the daemon) and then xc_fanout. It runs on
  MAIN when the settings are saved, and on every node from RootSignalsCronJob
  within a minute. The RootSignals keepalive and the hourly fanout_binary
  self-heal skip the daemon while the flag is set; `service boot` and run.sh,
  which cannot read settings, check the flag file.
- Every daemon-facing gate honours the switch. LicenseGate::fanoutUsable()
  and each FanoutClient control-socket call behave as if the daemon were
  down. IngestFeeder::forStream() returns a no-op feeder, and DelayCommand
  stops queueing segments for it. fanout_sync stops writing the daemon
  config and closes the pid-0 connection rows the stopped daemon left.
- The dashboard no longer reports "xc_fanout down" when it was switched off.

The pre-fanout delivery paths for fanout off follow in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
2026-09-25 10:07:46 +00:00
Claude 5e1cef37ce fix(install): keep SSH credentials off argv and disk; pin the node host key
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
2026-09-25 09:10:04 +00:00
Divarion_D d66d7ade68 fix(ui): word the panel banner as an authenticity check, not activation
The dashboard banner and CLI hint described the key as a panel activation,
which reads like a paid license. It only confirms the install is an original
XC_VM build, so say "verification" instead. Also adds the missing ru
translation for activation_key_rejected.
2026-09-23 21:53:06 +03:00
Divarion_D 559d7347c4 feat(reseller): dashboard location map, live connections and recent media
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
2026-09-22 22:29:06 +03:00
Divarion_D 415af7d91e refactor(admin): move the modules page onto ./table and ./api
The page was the only admin screen still POSTing to itself and rendering its
own rows server-side, so every action reloaded the whole page and the row
markup lived in a second place.

It now follows the standard: ./table?id=modules feeds the grid and each row
action goes to ./api?action=module, handled by the new ModuleAjaxController
behind the same adv/settings permission the page requires. Search and paging
run in PHP over listModules(), since modules come from the filesystem and
config rather than a query.

The outcome of an action is read back off the reloaded table, never inferred
from the request: the dependents guard can refuse a disable, and toggling one
module changes the dependency warnings shown on the others. An install runs
migrations, so the button shows "wait", stays disabled and the table is polled
every 2s for up to 30s; a refusal reports immediately instead of waiting out
the timeout, and a row that vanished settles rather than hanging.

The view drops jQuery and DataTables for fetch and plain DOM — it was the only
consumer of either here, and the <script> block sat before the footer that
loads jQuery, which broke the page outright with "jQuery is not defined". The
block now sits after renderUnifiedLayoutFooter('admin'), matching the other
admin views. Notifications no longer scrollIntoView, so acting on a row near
the bottom no longer yanks the page up.
2026-09-22 20:59:47 +03:00
Divarion_D 34d6f65de3 fix(admin): gate the activation banner on the key being accepted
The dashboard banner asked whether an activation key file exists on disk, so
any string written into it — expired, issued for a different HWID, or simply
wrong — made the warning disappear while the panel stayed locked. Deleting the
key on a live server produced no warning either, because the file check ran
against a stale path.

It now asks LicenseGate::licensed(), which is the same verdict the panel acts
on, so the banner and the actual lock can no longer disagree.

The save endpoint re-checks right after writing and reports a rejected key
instead of the unconditional "Activation key saved." The file is deliberately
left in place: the extension may cache its verdict for the request, so a
rejection here is not proof the key is bad, and silently deleting what the
admin just pasted would lose it.
2026-09-22 20:59:20 +03:00
AbdoAhmedElbanaaandDivarion_D 9164e3ee81 feat(reseller): group the sidebar into sections and fix active-item highlighting
Sidebar half of Rosmi720/XC_VM@8fa1e67f, with its permission regression fixed.

- Group the menu into captioned sections (Clients, Content, Category Templates,
  Tickets & Logs) instead of one flat list. A caption renders only when at least
  one of its items survives the permission gates, so a reseller without, say,
  any client permissions never sees an empty "Clients" heading.
- Drop a node whose children were all hidden by permissions and whose own url is
  '#': it used to render as a toggle that expanded to nothing.
- Fix active-item highlighting in two cases the plain basename match got wrong.
  `line` and `line?trial=1` share a basename, so both lit up at once; the trial
  entry now matches only while ?trial=1 is set and the plain one only while it
  is not. Detail routes (`ticket_view` under Tickets, `category_template` under
  Category Templates) left the sidebar with nothing highlighted, so an alias map
  folds them back onto their section.
- Category Templates was listed twice (top level and again under Content); it
  now has one entry, in its own section.

Changed on import:
- Keep LineService::canGenerateTrials() gating the three Generate Trial entries.
  Upstream replaced it with a plain create_line check, which would offer trial
  generation to resellers that may create lines but not trials.
- Drop the flat $xmMenu compatibility shim — nothing outside this file read it.

Also add three strings the sidebar asks for. A key with no en.ini entry makes
Translator return the key itself AND append it to the ini, so `sub_resellers`
was rendering raw in every language except Arabic; since resolveMissingKey()
falls back to the English value, the one entry fixes all of them.

Verified: 857 tests, make gates, every menu label and section title resolves to
a real translation. Visual layout still wants a browser pass.
2026-09-22 17:09:20 +03:00
AbdoAhmedElbanaaandDivarion_D 78d7b23212 feat(reseller): trial deep-links, voucher balance card, Host header guard
Safe subset of Rosmi720/XC_VM@8fa1e67f. The dashboard revamp and the sidebar
restructure from that commit are deliberately left out — see below.

- The sidebar's existing `?trial=1` links on Lines / MAG / Enigma now actually
  land on the Trial filter: each page picks the trial value from its own
  $rStatusFilters map (5 for lines, 4 for MAG and Enigma) when no explicit
  ?filter is given.
- Restyle the voucher credit-accounting card in active_code.php onto standard
  Vuexy surface classes instead of a hand-tinted primary panel. Element ids and
  the calculation are untouched, so the existing calculator JS still drives it.
- RequestGuard: fall back to '' for HTTP_HOST. A request can legitimately arrive
  without a Host header, and explode(':', null) is deprecated on PHP 8.
- Add the live_calculator string the new card asks for. Without it Translator
  returns the key itself AND appends it to the ini, so the card would render the
  literal "live_calculator" (which is how the placeholder appeared upstream).
  The Arabic wording is a first pass and welcomes a correction from its author.

Left out of this commit:
- ResellerDashboardController / dashboard.php: three fallbacks drop tenant
  scoping when the scoped query comes back empty — top countries re-queries
  lines_activity server-wide, latest movies and live streams re-run without the
  stream_ids restriction, and series and episodes are never scoped at all. It
  also pins unknown connections to a hardcoded 'EG' country, and moves ~10 raw
  SQL queries into a controller.
- The sidebar restructure: it replaces the LineService::canGenerateTrials()
  check with a plain create_line permission, which would show trial actions to
  resellers that may not generate trials.
- The Redis mGet guard for ResellerTableRenderer::handleLiveConnections, which
  Admin\TableController already carries. The method sits at cc=53 with no
  coverage, so the CRAP ratchet rightly refuses the hardening until it is
  tested — worth doing as its own change.

Verified: 857 tests, make gates, CRAP gate.
2026-09-22 17:00:30 +03:00
AbdoAhmedElbanaaandDivarion_D 5c6f9f920c fix(tickets): overhaul ticket lifecycle, visibility and admin interactions
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('&nbsp; ', ...) padding hack that rendered literal
  '&nbsp;' 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.
2026-09-22 16:32:39 +03:00
AbdoAhmedElbanaaandDivarion_D 73b8bbbdd3 chore(i18n): update translation strings for download modal and copy actions
Port of Rosmi720/XC_VM@16e00cb. Give the download-modal labels their final
wording (custom_format_link) and add the select_an_option string.

Adapted to this tree: ar.ini is absent here, so only en.ini was updated; the
custom_format_link/download keys already existed from the modal feature port.
2026-09-21 19:48:28 +03:00
AbdoAhmedElbanaaandDivarion_D bdaccc9805 feat(ui): enhance download playlist modal with quick stream links and Xtream API credentials
Port of Rosmi720/XC_VM@064c6c92. Rework the Download Playlist modal on the admin
and reseller line pages to expose ready-to-use links and player credentials:

- Quick Stream Links: M3U Plus, M3U Simple and XMLTV EPG, each with copy and
  open buttons.
- Xtream Codes API Details card: server host, port, username and password
  (with show/hide), plus a "Copy All Details" action.
- Format selector gains MPEGTS and RTMP outputs; a copyTextWithFeedback() JS
  helper drives the per-field copy buttons.
- New en.ini keys for the above.

Adapted to this tree: ar.ini is absent here, so only en.ini was updated, and
the two placeholder key values (custom_format_link/download) were given real
English labels.
2026-09-21 19:32:15 +03:00
AbdoAhmedElbanaaandDivarion_D cf471013b9 feat(stream): implement Encrypt Playlists across live and VOD
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.
2026-09-21 19:11:10 +03:00
Divarion_D f8a04a1281 feat(dashboard): activation banner + self-service key entry
Shows an activation nudge at the top of the admin dashboard while
MAIN_HOME/config/activation_key is absent — the same file xcvm_core reads
for license_valid(). The banner surfaces the panel HWID (\XC_VM::install_id,
guarded), links to the self-service activation page, and provides a paste
field that submits the key over ajax.

- MiscAjaxController::saveActivationKey (action=save_activation_key): XHR +
  adv/settings gated, validates the XCVM1.<b64url>.<b64url> format, writes
  the key to config/activation_key (0600).
- Strings live in en.ini/ru.ini (no hard-coded fallbacks in the view).

UI hint only: it never blocks the dashboard; real enforcement stays in the
extension and at the fanout choke points via LicenseGate.
2026-09-18 20:20:28 +03:00
d217bb1b48 feat(settings): choose the fanout debug log from a dropdown
The fanout debug field was free text (empty, "all", or a comma list of
category names typed from the tooltip), so a typo silently meant off.
Make it a dropdown of every value the daemon takes: Off, All categories,
or one category. FanoutConfig::DEBUG_CATEGORIES is now the single list
both the dropdown and debugCats() use; a combination saved before stays
selectable as it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4P9p5BXijMoGXd31tN6Zp
2026-09-18 09:57:17 +00:00
rootandClaude Opus 4.8 ba524d57f9 fix(fanout): make the debug toggle actually reachable from settings
The panel mapped fanout_debug -> the daemon's debug_cats, but nothing could
set it: there was no `fanout_debug` column, so QueryHelper::verifyPostTable
(which filters a settings POST against the table's real columns) dropped it,
and there was no field in the settings form to post it with. The toggle was
inert -- FanoutConfig always wrote debug_cats "" (off).

Add the missing column (migration 025 + the install schema), a text field in
the admin settings form beside the other fanout knobs, and the fanout_debug
label in all seven locales. Empty = off (default), "all", or a comma list of
the daemon's categories; FanoutConfig normalises it as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 08:03:53 +00:00
rootandClaude Opus 5 b6ac7d4953 fix(i18n): add the manage_category_templates menu label
CoreNavbarProvider labels the category templates menu entry with
manage_category_templates, which no language file defined, so the raw
key showed in the menu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4P9p5BXijMoGXd31tN6Zp
2026-09-17 08:15:16 +00:00
Divarion_DandAbdoAhmedElbanaa 71602c7cc5 i18n: restore ac_* strings and add the used feature keys
The integration had dropped all 208 ac_* (Active Codes) keys from every
language file while adding new feature keys, so the Active Codes UI rendered
raw key names. Restore the language files to their committed state (ac_*
intact) and append only the 57 new keys that are actually referenced in the
panel (59 unused keys from the re-send are skipped). Additive-only; the files
parse under the app's INI_SCANNER_RAW.

Co-Authored-By: AbdoAhmedElbanaa <115952879+AbdoAhmedElbanaa@users.noreply.github.com>
2026-09-15 20:47:45 +03:00
rootandClaude Opus 5 74ef365f7d feat(streaming): tamper-proof stream tokens (AES-256-GCM), switched on per panel
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
2026-09-13 08:49:06 +00:00
Divarion_D bf2c477de1 i18n(en): add keys for bouquet sort, responsive tables and profile appearance 2026-09-12 16:57:48 +03:00
Divarion_D 468e70a1f8 refactor(active-codes): unify tables to standard DataTable and localize all pages
- Rebuild the batch managers (admin + reseller) on the standard client-side
  DataTable template; align the codes tables' toolbar, responsive control column
  and action buttons with the other admin tables (reseller order array updated).
- Localize every user-facing string across the 8 Active Codes views via
  $language::get(); add the missing keys to en.ini (navbar labels included, which
  previously rendered raw keys).
- Fix clipboard copy over HTTP (temp textarea inside the open modal + explicit
  selection) and the mass-edit "no matching codes" (read row.id from the keyed
  payload); replace alert()/confirm() with xcToast()/xcConfirm().
2026-09-12 13:26:07 +03:00
rootandClaude Opus 5 bb949d18b7 feat(admin): show each stream's producer, CPU and memory on the streams page
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
2026-09-11 10:58:50 +00:00
rootandClaude Opus 5 0603e5d2ab feat(fanout): add the fanout_supervise setting
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
2026-09-11 09:20:03 +00:00
rootandClaude Opus 5 589f4f7ee2 Revert PR #1 (feat/daemon-monitor): panel-side encoder supervision
Reverts the five commits PR #1 brought in and the two test follow-ups made
after it merged:

  a1d454fe Enhance assertions in FanoutConfigTest
  19607a91 Update StreamProcessBuildLiveTest.php
  b3714269 fix(fanout): stop, force and rogue-kill must account for the daemon
  cea545fe feat(fanout): reconcile supervised streams back into the panel
  8e24a242 feat(fanout): make encoder supervision a real, settable option
  8244ece1 feat(fanout): health policy, source forcing and supervision reconcile
  56c0619e feat(fanout): hand live encoders to the daemon to supervise

Only those. The upstream commits that reached main through the same merge
(the GeoIP refactor, release 2.5.0, the update guard, the mass-page fix)
stay: reverting the merge commit itself would have taken them out too.

The panel side of supervision is re-done in the commits that follow,
together with the xc_fanout native remuxer (xc_fanout 0.13.0): every live
stream handed to the daemon's monitor, copy-only streams run on
`xc_fanout remux` instead of ffmpeg, the ffmpeg command as an explicit
fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012QL93N6dzGkmKoQgA4oh16
2026-09-11 09:19:54 +00:00
obscuremindandClaude Opus 5 8e24a2427a feat(fanout): make encoder supervision a real, settable option
Production-readiness pass on the panel side of daemon encoder supervision.

Until now the feature was gated on a setting that did not exist as a
column, so it could only be enabled by hand-editing the database and the
daemon never learned about it at all.

* Migration 018 adds `fanout_supervise`, off for every existing install.
* Admin -> Settings gains the toggle, alongside the other fanout tuning.
* SettingsService saves it (it is a checkbox, so it has to be in the
  boolean list or it can never be turned back off).
* FanoutConfig writes it into the daemon's config file as `supervise`,
  which is how the node learns it may supervise at all. Both halves must
  be on for anything to change, and either one off is a full rollback.
* StreamProcess reads `fanout_supervise` rather than the placeholder
  name, matching the panel's `fanout_*` convention.

Also: the recorded command file now says WHO ran the stream. `_.fanout`
when the daemon owns the process (the bare command it was handed) and
`_.ffmpeg` when this node ran it itself (with the redirect-and-background
tail). Two names rather than one because the first question in any
incident is which path the stream took, and a single filename cannot
answer it; the stale one is removed so a stream that switched paths does
not leave a lie behind.

FanoutConfigTest covers the new key both ways, including that a settings
array predating it reads as off -- that absence is the upgrade path for
every existing install.

Verified: php -l clean on every changed file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 21:34:50 +01:00
rootandClaude Opus 5 553aed89ff feat(fanout): expose the daemon's source backend as a panel setting
xc_fanout 0.12.0 can convert a non-mp2t source to MPEG-TS in-process instead of
spawning an ffmpeg child per stream. For the common IPTV case — HLS whose
segments are already MPEG-TS — that child was doing little more than
concatenating bytes the daemon can concatenate itself, at ~27 MB RSS apiece,
plus a process spawn and a probe window on every on-demand join and reconnect.

The daemon reads which path to take from `source_backend` in its config.json;
this adds the panel setting that writes it.

  auto (default) — convert natively where the daemon's reader can, ffmpeg for
                   everything it declines
  ffmpeg         — always spawn ffmpeg; the pre-0.12 behaviour and the
                   kill-switch if a native pull ever misbehaves
  native         — native only, no fallback. Diagnostic: it answers "what is
                   actually eligible on this node", and a declined source there
                   is a dead channel rather than a slightly more expensive one.

auto is the default because the fallback makes it strictly safer than
ffmpeg-always: anything the native reader will not take (fMP4/CMAF HLS, RTMP,
SRT, RTSP, AES-128 encrypted sources, anything it cannot positively identify)
runs exactly the pipeline it ran before.

A `<select>`, not a numeric input, so it is deliberately kept out of the
numeric-coercion list in settings.php that would otherwise mangle the string.
An unrecognised value maps to auto rather than being written through — the
daemon clamps unknown values itself, but a config file we write should not carry
a backend that does not exist, and a typo must never take channels off air.

Touches the six places every fanout_* setting lives: migration 017 for upgrades,
database.sql for fresh installs (column, INSERT list and VALUES — 276/276 still
aligned), the FanoutConfig mapping, the settings UI, all seven language files,
and the unit test. SettingsService needs no change: its whitelist is only for
checkboxes, and verifyPostTable already maps a posted field to its column.

Verified against the real daemon end to end: the panel writing "ffmpeg" boots
xc_fanout with backend=ffmpeg, and changing it to "auto" is picked up live on
the next config poll. Read-modify-write still preserves daemon keys the panel
does not manage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGCQcWGMg1Wn8RdCxYoioN
2026-09-09 19:39:17 +00:00
Divarion_D 7caff1513a feat(updates): per-repository update channels (stable/beta)
Replace the single global `update_channel` setting with independent
per-repository channels for MAIN (panel), BIN (compiled binaries) and
FANOUT (xc_fanout daemon). The GeoLite/ASN (UPDATE) and proxy (PROXY)
repos have no channel of their own and follow the MAIN channel.

- Add XcVm\Core\Updates\UpdateChannels resolver (main/bin/fanout/forRepo).
- Migration 016: add update_channel_main/bin/fanout, copy the previous
  selection into all three (unstable -> beta), drop update_channel.
- Settings UI: one "Updates" section with three channel selectors.
- Route every GitHubReleases call site through the resolver.
- LbInstallFlow: resolve the BIN tag channel-aware (was always /latest),
  so a BIN beta channel provisions LB nodes with pre-release binaries.
- XcvmCoreCommand: map the BIN channel to a git branch (stable -> main,
  beta -> beta) with a fallback to the stable branch when beta is absent.
2026-09-09 20:09:34 +03:00
Divarion_D ef5994b34d feat(admin-ui): fix navbar labels & missing i18n keys
- Rename the admin 'User Lines' items to Add Line / Manage Lines / Mass
  edit lines (they manage lines, matching the reseller panel).
- Drop the duplicate Live Connections entry from the Dashboard group
  (it stays under Logs).
- Add missing en.ini keys (mass_edit_lines, mass_edit_mags,
  mass_edit_enigmas, user_logs, generate_trial, no_information, restore,
  confirm_uninstall_module) so navbar/labels no longer echo raw keys.
2026-09-06 22:16:52 +03:00
Divarion_D abaa7b0b6b feat(auth-ui): redesign admin & reseller login as sci-fi HUD
Replace the admin and reseller login pages with a single-look, dark
"Core Access" HUD console: a full-bleed cyberpunk backdrop with a
centred angular panel, red LED-style edge bars (glowing 45° corner
brackets + neutral edge runs), textured inputs with icon wells and
red accent bars, a chevron LOGIN button, and a subtle living animation
(phase-staggered glow breathing + a slow hue-drift shimmer), gated
behind prefers-reduced-motion.

- admin/login.php: rewritten to the HUD markup; PHP login contract
  preserved 1:1 (flood check, Authenticator, referrer/setup/dashboard
  redirects, reCAPTCHA, status messages). Intentionally not theme/hue
  aware.
- reseller/login.php: same HUD, adapted contract (STATUS_NOT_RESELLER,
  controller-provided $referrer). Reuses the admin login.css and bg via
  the Public/assets/reseller -> admin symlink.
- CSS extracted to assets/admin/new/xcvm/login.css (shared by both).
- Add xcvm-login-bg.png; drop the old login-bg.mp4 video.
- i18n: add admin_access / reseller_access.
2026-09-06 17:21:31 +03:00